Initial Claude Run
This commit is contained in:
327
frontend/src/App.js
Normal file
327
frontend/src/App.js
Normal file
@@ -0,0 +1,327 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
||||
// Layout components
|
||||
import Layout from './components/Layout/Layout';
|
||||
import AuthLayout from './components/Layout/AuthLayout';
|
||||
|
||||
// Auth pages
|
||||
import Login from './pages/Auth/Login';
|
||||
import Register from './pages/Auth/Register';
|
||||
import ForgotPassword from './pages/Auth/ForgotPassword';
|
||||
|
||||
// Main app pages
|
||||
import Dashboard from './pages/Dashboard/Dashboard';
|
||||
import Properties from './pages/Properties/Properties';
|
||||
import PropertyDetail from './pages/Properties/PropertyDetail';
|
||||
import Equipment from './pages/Equipment/Equipment';
|
||||
import Products from './pages/Products/Products';
|
||||
import Applications from './pages/Applications/Applications';
|
||||
import ApplicationPlan from './pages/Applications/ApplicationPlan';
|
||||
import ApplicationLog from './pages/Applications/ApplicationLog';
|
||||
import History from './pages/History/History';
|
||||
import Weather from './pages/Weather/Weather';
|
||||
import Profile from './pages/Profile/Profile';
|
||||
|
||||
// Admin pages
|
||||
import AdminDashboard from './pages/Admin/AdminDashboard';
|
||||
import AdminUsers from './pages/Admin/AdminUsers';
|
||||
import AdminProducts from './pages/Admin/AdminProducts';
|
||||
|
||||
// Error pages
|
||||
import NotFound from './pages/Error/NotFound';
|
||||
import Unauthorized from './pages/Error/Unauthorized';
|
||||
|
||||
// Loading component
|
||||
import LoadingSpinner from './components/UI/LoadingSpinner';
|
||||
|
||||
// Create a client for React Query
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on 401 (unauthorized) or 403 (forbidden)
|
||||
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
cacheTime: 10 * 60 * 1000, // 10 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Protected Route component
|
||||
const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (adminOnly && user.role !== 'admin') {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
// Public Route component (redirects to dashboard if already authenticated)
|
||||
const PublicRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AuthLayout>
|
||||
<Login />
|
||||
</AuthLayout>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AuthLayout>
|
||||
<Register />
|
||||
</AuthLayout>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AuthLayout>
|
||||
<ForgotPassword />
|
||||
</AuthLayout>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/properties"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Properties />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/properties/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<PropertyDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/equipment"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Equipment />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Products />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/applications"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Applications />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/applications/plan"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ApplicationPlan />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/applications/log"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ApplicationLog />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<History />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/weather"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Weather />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Profile />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<Layout>
|
||||
<AdminDashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<Layout>
|
||||
<AdminUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/products"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<Layout>
|
||||
<AdminProducts />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error Routes */}
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
<Route path="/404" element={<NotFound />} />
|
||||
|
||||
{/* Redirects */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
|
||||
{/* Global Toast Notifications */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#fff',
|
||||
color: '#374151',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
43
frontend/src/components/Layout/AuthLayout.js
Normal file
43
frontend/src/components/Layout/AuthLayout.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
const AuthLayout = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-grass-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-16 w-16 bg-primary-600 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
className="h-10 w-10 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">TurfTracker</h1>
|
||||
<p className="text-lg text-gray-600">Professional Lawn Care Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow-xl rounded-lg sm:px-10 border border-gray-200">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Track your lawn care with confidence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
342
frontend/src/components/Layout/Layout.js
Normal file
342
frontend/src/components/Layout/Layout.js
Normal file
@@ -0,0 +1,342 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
HomeIcon,
|
||||
MapIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
BeakerIcon,
|
||||
CalendarDaysIcon,
|
||||
ClockIcon,
|
||||
CloudIcon,
|
||||
UserIcon,
|
||||
Cog6ToothIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
BellIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {
|
||||
HomeIcon as HomeIconSolid,
|
||||
MapIcon as MapIconSolid,
|
||||
WrenchScrewdriverIcon as WrenchIconSolid,
|
||||
BeakerIcon as BeakerIconSolid,
|
||||
CalendarDaysIcon as CalendarIconSolid,
|
||||
ClockIcon as ClockIconSolid,
|
||||
CloudIcon as CloudIconSolid,
|
||||
UserIcon as UserIconSolid,
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import LoadingSpinner from '../UI/LoadingSpinner';
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: HomeIcon,
|
||||
iconSolid: HomeIconSolid,
|
||||
},
|
||||
{
|
||||
name: 'Properties',
|
||||
href: '/properties',
|
||||
icon: MapIcon,
|
||||
iconSolid: MapIconSolid,
|
||||
},
|
||||
{
|
||||
name: 'Equipment',
|
||||
href: '/equipment',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
iconSolid: WrenchIconSolid,
|
||||
},
|
||||
{
|
||||
name: 'Products',
|
||||
href: '/products',
|
||||
icon: BeakerIcon,
|
||||
iconSolid: BeakerIconSolid,
|
||||
},
|
||||
{
|
||||
name: 'Applications',
|
||||
href: '/applications',
|
||||
icon: CalendarDaysIcon,
|
||||
iconSolid: CalendarIconSolid,
|
||||
},
|
||||
{
|
||||
name: 'History',
|
||||
href: '/history',
|
||||
icon: ClockIcon,
|
||||
iconSolid: ClockIconSolid,
|
||||
},
|
||||
{
|
||||
name: 'Weather',
|
||||
href: '/weather',
|
||||
icon: CloudIcon,
|
||||
iconSolid: CloudIconSolid,
|
||||
},
|
||||
];
|
||||
|
||||
const adminNavigation = [
|
||||
{
|
||||
name: 'Admin Dashboard',
|
||||
href: '/admin',
|
||||
icon: Cog6ToothIcon,
|
||||
},
|
||||
{
|
||||
name: 'Manage Users',
|
||||
href: '/admin/users',
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
name: 'Manage Products',
|
||||
href: '/admin/products',
|
||||
icon: BeakerIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" message="Loading..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`lg:hidden ${sidebarOpen ? 'relative z-40' : ''}`}>
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-600 bg-opacity-75"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white shadow-xl">
|
||||
<div className="flex h-16 items-center justify-between px-6 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-primary-600">TurfTracker</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-4 py-6">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href ||
|
||||
(item.href !== '/dashboard' && location.pathname.startsWith(item.href));
|
||||
const Icon = isActive ? item.iconSolid : item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 my-4" />
|
||||
<div className="px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Administration
|
||||
</h3>
|
||||
</div>
|
||||
{adminNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user.firstName?.[0]}{user.lastName?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="nav-link-inactive"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<UserIcon className="h-5 w-5 mr-3" />
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="nav-link-inactive w-full text-left"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="h-5 w-5 mr-3" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-white border-r border-gray-200">
|
||||
<div className="flex h-16 items-center px-6 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-primary-600">TurfTracker</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-4 py-6">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href ||
|
||||
(item.href !== '/dashboard' && location.pathname.startsWith(item.href));
|
||||
const Icon = isActive ? item.iconSolid : item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 my-4" />
|
||||
<div className="px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Administration
|
||||
</h3>
|
||||
</div>
|
||||
{adminNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
>
|
||||
<item.icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user.firstName?.[0]}{user.lastName?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Link to="/profile" className="nav-link-inactive">
|
||||
<UserIcon className="h-5 w-5 mr-3" />
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="nav-link-inactive w-full text-left"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="h-5 w-5 mr-3" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 lg:hidden">
|
||||
<div className="flex h-16 items-center justify-between px-4">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-500 hover:text-gray-600"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<h1 className="text-lg font-semibold text-gray-900">TurfTracker</h1>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="text-gray-400 hover:text-gray-500">
|
||||
<BellIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="h-6 w-6 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-white">
|
||||
{user.firstName?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
43
frontend/src/components/UI/LoadingSpinner.js
Normal file
43
frontend/src/components/UI/LoadingSpinner.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const LoadingSpinner = ({
|
||||
size = 'md',
|
||||
color = 'primary',
|
||||
className = '',
|
||||
message = null
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16',
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'border-primary-600',
|
||||
white: 'border-white',
|
||||
gray: 'border-gray-600',
|
||||
grass: 'border-grass-600',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('flex flex-col items-center justify-center', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-spin rounded-full border-2 border-gray-200',
|
||||
sizeClasses[size],
|
||||
colorClasses[color],
|
||||
'border-t-transparent'
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
{message && (
|
||||
<p className="mt-2 text-sm text-gray-600 text-center">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
232
frontend/src/contexts/AuthContext.js
Normal file
232
frontend/src/contexts/AuthContext.js
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import { authAPI } from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Initial state
|
||||
const initialState = {
|
||||
user: null,
|
||||
token: localStorage.getItem('authToken'),
|
||||
loading: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Action types
|
||||
const actionTypes = {
|
||||
SET_LOADING: 'SET_LOADING',
|
||||
LOGIN_SUCCESS: 'LOGIN_SUCCESS',
|
||||
LOGOUT: 'LOGOUT',
|
||||
SET_ERROR: 'SET_ERROR',
|
||||
CLEAR_ERROR: 'CLEAR_ERROR',
|
||||
UPDATE_USER: 'UPDATE_USER',
|
||||
};
|
||||
|
||||
// Reducer
|
||||
const authReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case actionTypes.SET_LOADING:
|
||||
return {
|
||||
...state,
|
||||
loading: action.payload,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case actionTypes.LOGIN_SUCCESS:
|
||||
localStorage.setItem('authToken', action.payload.token);
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case actionTypes.LOGOUT:
|
||||
localStorage.removeItem('authToken');
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case actionTypes.SET_ERROR:
|
||||
return {
|
||||
...state,
|
||||
error: action.payload,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
case actionTypes.CLEAR_ERROR:
|
||||
return {
|
||||
...state,
|
||||
error: null,
|
||||
};
|
||||
|
||||
case actionTypes.UPDATE_USER:
|
||||
return {
|
||||
...state,
|
||||
user: { ...state.user, ...action.payload },
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Create context
|
||||
const AuthContext = createContext();
|
||||
|
||||
// Custom hook to use auth context
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Auth provider component
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// Check if user is authenticated on app load
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
|
||||
if (!token) {
|
||||
dispatch({ type: actionTypes.SET_LOADING, payload: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authAPI.getCurrentUser();
|
||||
dispatch({
|
||||
type: actionTypes.LOGIN_SUCCESS,
|
||||
payload: {
|
||||
user: response.data.user,
|
||||
token,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
localStorage.removeItem('authToken');
|
||||
dispatch({ type: actionTypes.LOGOUT });
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
// Login function
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
dispatch({ type: actionTypes.SET_LOADING, payload: true });
|
||||
dispatch({ type: actionTypes.CLEAR_ERROR });
|
||||
|
||||
const response = await authAPI.login(credentials);
|
||||
|
||||
dispatch({
|
||||
type: actionTypes.LOGIN_SUCCESS,
|
||||
payload: response.data,
|
||||
});
|
||||
|
||||
toast.success('Welcome back!');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || 'Login failed. Please try again.';
|
||||
dispatch({ type: actionTypes.SET_ERROR, payload: errorMessage });
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
// Register function
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
dispatch({ type: actionTypes.SET_LOADING, payload: true });
|
||||
dispatch({ type: actionTypes.CLEAR_ERROR });
|
||||
|
||||
const response = await authAPI.register(userData);
|
||||
|
||||
dispatch({
|
||||
type: actionTypes.LOGIN_SUCCESS,
|
||||
payload: response.data,
|
||||
});
|
||||
|
||||
toast.success('Account created successfully!');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || 'Registration failed. Please try again.';
|
||||
dispatch({ type: actionTypes.SET_ERROR, payload: errorMessage });
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
// Logout function
|
||||
const logout = () => {
|
||||
dispatch({ type: actionTypes.LOGOUT });
|
||||
toast.success('Logged out successfully');
|
||||
};
|
||||
|
||||
// Update user profile
|
||||
const updateUser = (userData) => {
|
||||
dispatch({ type: actionTypes.UPDATE_USER, payload: userData });
|
||||
};
|
||||
|
||||
// Change password
|
||||
const changePassword = async (passwordData) => {
|
||||
try {
|
||||
await authAPI.changePassword(passwordData);
|
||||
toast.success('Password changed successfully');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || 'Failed to change password';
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
// Forgot password
|
||||
const forgotPassword = async (email) => {
|
||||
try {
|
||||
await authAPI.forgotPassword(email);
|
||||
toast.success('Password reset instructions sent to your email');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || 'Failed to send reset email';
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
// Clear error
|
||||
const clearError = () => {
|
||||
dispatch({ type: actionTypes.CLEAR_ERROR });
|
||||
};
|
||||
|
||||
// Context value
|
||||
const value = {
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
isAuthenticated: !!state.user,
|
||||
isAdmin: state.user?.role === 'admin',
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateUser,
|
||||
changePassword,
|
||||
forgotPassword,
|
||||
clearError,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
5
frontend/src/hooks/useAuth.js
Normal file
5
frontend/src/hooks/useAuth.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useAuth as useAuthContext } from '../contexts/AuthContext';
|
||||
|
||||
// Re-export the useAuth hook from the context
|
||||
// This allows for easier imports and potential future enhancements
|
||||
export const useAuth = useAuthContext;
|
||||
206
frontend/src/index.css
Normal file
206
frontend/src/index.css
Normal file
@@ -0,0 +1,206 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Base styles */
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
/* Component styles */
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-grass-600 text-white hover:bg-grass-700 focus:ring-grass-500;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@apply input border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply border-b border-gray-200 pb-4 mb-4;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@apply flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
@apply nav-link bg-primary-100 text-primary-700;
|
||||
}
|
||||
|
||||
.nav-link-inactive {
|
||||
@apply nav-link text-gray-600 hover:text-gray-900 hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@apply badge bg-grass-100 text-grass-800;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
@apply badge bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
@apply badge bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply badge bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply badge bg-gray-100 text-gray-800;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility styles */
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
.animation-delay-600 {
|
||||
animation-delay: 600ms;
|
||||
}
|
||||
|
||||
.glass {
|
||||
@apply bg-white/80 backdrop-blur-sm border border-white/20;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading animations */
|
||||
.loading-dots {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.loading-dots::after {
|
||||
content: '';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%, 20% { content: ''; }
|
||||
40% { content: '.'; }
|
||||
60% { content: '..'; }
|
||||
80%, 100% { content: '...'; }
|
||||
}
|
||||
|
||||
/* Map styles */
|
||||
.mapboxgl-popup-content {
|
||||
@apply rounded-lg shadow-lg;
|
||||
}
|
||||
|
||||
.mapboxgl-popup-close-button {
|
||||
@apply text-gray-400 hover:text-gray-600;
|
||||
}
|
||||
|
||||
/* React Query loading states */
|
||||
.query-loading {
|
||||
@apply animate-pulse;
|
||||
}
|
||||
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 640px) {
|
||||
.mobile-full-width {
|
||||
width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-break {
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode support (future enhancement) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dark-mode {
|
||||
@apply bg-gray-900 text-white;
|
||||
}
|
||||
}
|
||||
19
frontend/src/index.js
Normal file
19
frontend/src/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// Hide loading indicator
|
||||
const loadingIndicator = document.getElementById('loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
setTimeout(() => {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
14
frontend/src/pages/Admin/AdminDashboard.js
Normal file
14
frontend/src/pages/Admin/AdminDashboard.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Admin Dashboard</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Admin dashboard coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
14
frontend/src/pages/Admin/AdminProducts.js
Normal file
14
frontend/src/pages/Admin/AdminProducts.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const AdminProducts = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage Products</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Product management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProducts;
|
||||
14
frontend/src/pages/Admin/AdminUsers.js
Normal file
14
frontend/src/pages/Admin/AdminUsers.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const AdminUsers = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage Users</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">User management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsers;
|
||||
14
frontend/src/pages/Applications/ApplicationLog.js
Normal file
14
frontend/src/pages/Applications/ApplicationLog.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const ApplicationLog = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Log Application</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application logging coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationLog;
|
||||
14
frontend/src/pages/Applications/ApplicationPlan.js
Normal file
14
frontend/src/pages/Applications/ApplicationPlan.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const ApplicationPlan = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Plan Application</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application planning coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationPlan;
|
||||
14
frontend/src/pages/Applications/Applications.js
Normal file
14
frontend/src/pages/Applications/Applications.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Applications = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Applications</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Applications;
|
||||
103
frontend/src/pages/Auth/ForgotPassword.js
Normal file
103
frontend/src/pages/Auth/ForgotPassword.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const { forgotPassword, loading } = useAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
reset,
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await forgotPassword(data.email);
|
||||
if (result.success) {
|
||||
reset();
|
||||
} else {
|
||||
setError('root', {
|
||||
type: 'manual',
|
||||
message: result.error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Forgot your password?</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{errors.root && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.root.message}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={errors.email ? 'input-error' : 'input'}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^\S+@\S+$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
) : (
|
||||
'Send reset link'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
203
frontend/src/pages/Auth/Login.js
Normal file
203
frontend/src/pages/Auth/Login.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||
|
||||
const Login = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { login, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || '/dashboard';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await login(data);
|
||||
if (result.success) {
|
||||
navigate(from, { replace: true });
|
||||
} else {
|
||||
setError('root', {
|
||||
type: 'manual',
|
||||
message: result.error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth endpoint
|
||||
window.location.href = `${process.env.REACT_APP_API_URL || 'http://localhost:5000'}/api/auth/google`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Sign in to your account</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
create a new account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{errors.root && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.root.message}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={errors.email ? 'input-error' : 'input'}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^\S+@\S+$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={errors.password ? 'input-error pr-10' : 'input pr-10'}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
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.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="ml-2">Sign in with Google</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
248
frontend/src/pages/Auth/Register.js
Normal file
248
frontend/src/pages/Auth/Register.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||
|
||||
const Register = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { register: registerUser, loading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setError,
|
||||
} = useForm();
|
||||
|
||||
const password = watch('password');
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await registerUser(data);
|
||||
if (result.success) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
setError('root', {
|
||||
type: 'manual',
|
||||
message: result.error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Create your account</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Sign in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{errors.root && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.root.message}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="label">
|
||||
First name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
className={errors.firstName ? 'input-error' : 'input'}
|
||||
{...register('firstName', {
|
||||
required: 'First name is required',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'First name must be at least 2 characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.firstName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="label">
|
||||
Last name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
className={errors.lastName ? 'input-error' : 'input'}
|
||||
{...register('lastName', {
|
||||
required: 'Last name is required',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Last name must be at least 2 characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.lastName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={errors.email ? 'input-error' : 'input'}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^\S+@\S+$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
className={errors.password ? 'input-error pr-10' : 'input pr-10'}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Password must be at least 8 characters',
|
||||
},
|
||||
pattern: {
|
||||
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
message: 'Password must contain uppercase, lowercase, number, and special character',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="label">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={errors.confirmPassword ? 'input-error' : 'input'}
|
||||
{...register('confirmPassword', {
|
||||
required: 'Please confirm your password',
|
||||
validate: (value) =>
|
||||
value === password || 'Passwords do not match',
|
||||
})}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="agree-terms"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
{...register('agreeTerms', {
|
||||
required: 'You must agree to the terms and conditions',
|
||||
})}
|
||||
/>
|
||||
<label htmlFor="agree-terms" className="ml-2 block text-sm text-gray-900">
|
||||
I agree to the{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-primary-600 hover:text-primary-500"
|
||||
target="_blank"
|
||||
>
|
||||
Terms and Conditions
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-primary-600 hover:text-primary-500"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
{errors.agreeTerms && (
|
||||
<p className="text-sm text-red-600">{errors.agreeTerms.message}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
309
frontend/src/pages/Dashboard/Dashboard.js
Normal file
309
frontend/src/pages/Dashboard/Dashboard.js
Normal file
@@ -0,0 +1,309 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
MapIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
BeakerIcon,
|
||||
CalendarDaysIcon,
|
||||
ClockIcon,
|
||||
CloudIcon,
|
||||
PlusIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
name: 'Add Property',
|
||||
href: '/properties',
|
||||
icon: MapIcon,
|
||||
description: 'Set up a new lawn area',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
name: 'Plan Application',
|
||||
href: '/applications/plan',
|
||||
icon: CalendarDaysIcon,
|
||||
description: 'Schedule a treatment',
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
name: 'Add Equipment',
|
||||
href: '/equipment',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
description: 'Register new equipment',
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
name: 'Log Application',
|
||||
href: '/applications/log',
|
||||
icon: ClockIcon,
|
||||
description: 'Record completed work',
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Properties',
|
||||
value: '3',
|
||||
change: '+1',
|
||||
changeType: 'positive',
|
||||
icon: MapIcon,
|
||||
},
|
||||
{
|
||||
name: 'This Month\'s Applications',
|
||||
value: '12',
|
||||
change: '+4',
|
||||
changeType: 'positive',
|
||||
icon: CalendarDaysIcon,
|
||||
},
|
||||
{
|
||||
name: 'Equipment Items',
|
||||
value: '8',
|
||||
change: '+2',
|
||||
changeType: 'positive',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
{
|
||||
name: 'Products Used',
|
||||
value: '15',
|
||||
change: '+3',
|
||||
changeType: 'positive',
|
||||
icon: BeakerIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const recentActivity = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'application',
|
||||
title: 'Applied fertilizer to Front Yard',
|
||||
property: 'Main Property',
|
||||
date: '2 hours ago',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'plan',
|
||||
title: 'Scheduled weed control treatment',
|
||||
property: 'Back Yard',
|
||||
date: 'Tomorrow at 9:00 AM',
|
||||
status: 'planned',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'equipment',
|
||||
title: 'Added new broadcast spreader',
|
||||
property: null,
|
||||
date: '3 days ago',
|
||||
status: 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
const upcomingTasks = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Pre-emergent herbicide application',
|
||||
property: 'Main Property - Front Yard',
|
||||
date: 'March 15, 2024',
|
||||
weather: 'Partly cloudy, 65°F',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Spring fertilizer application',
|
||||
property: 'Side Property - Back Lawn',
|
||||
date: 'March 20, 2024',
|
||||
weather: 'Sunny, 72°F',
|
||||
priority: 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome back, {user?.firstName}!
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Here's what's happening with your lawn care today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="card">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<stat.icon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-500">{stat.name}</p>
|
||||
<div className="flex items-baseline">
|
||||
<p className="text-2xl font-semibold text-gray-900">{stat.value}</p>
|
||||
<span className={`ml-2 text-sm font-medium ${
|
||||
stat.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stat.change}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Quick Actions */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-lg font-medium text-gray-900">Quick Actions</h3>
|
||||
<p className="text-sm text-gray-500">Get started with common tasks</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{quickActions.map((action) => (
|
||||
<Link
|
||||
key={action.name}
|
||||
to={action.href}
|
||||
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-gray-300 hover:shadow-sm transition-all duration-200"
|
||||
>
|
||||
<div className={`flex-shrink-0 p-2 rounded-lg ${action.color}`}>
|
||||
<action.icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{action.name}</p>
|
||||
<p className="text-xs text-gray-500">{action.description}</p>
|
||||
</div>
|
||||
<PlusIcon className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity & Upcoming Tasks */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Recent Activity */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-lg font-medium text-gray-900">Recent Activity</h3>
|
||||
<Link
|
||||
to="/history"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start space-x-3">
|
||||
<div className={`flex-shrink-0 p-1 rounded-full ${
|
||||
activity.status === 'completed' ? 'bg-green-100' : 'bg-blue-100'
|
||||
}`}>
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
activity.status === 'completed' ? 'bg-green-600' : 'bg-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900">{activity.title}</p>
|
||||
{activity.property && (
|
||||
<p className="text-xs text-gray-500">{activity.property}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">{activity.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Tasks */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-lg font-medium text-gray-900">Upcoming Tasks</h3>
|
||||
<Link
|
||||
to="/applications"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{upcomingTasks.map((task) => (
|
||||
<div key={task.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900">{task.title}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{task.property}</p>
|
||||
<div className="flex items-center mt-2 space-x-4">
|
||||
<span className="text-xs text-gray-500">{task.date}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<CloudIcon className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">{task.weather}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
task.priority === 'high'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: task.priority === 'medium'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather Widget */}
|
||||
<div className="mt-8">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Today's Weather</h3>
|
||||
<Link
|
||||
to="/weather"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Detailed forecast
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<CloudIcon className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900">72°F</p>
|
||||
<p className="text-sm text-gray-500">Partly cloudy</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Wind:</span>
|
||||
<span className="text-sm font-medium text-gray-900">5 mph</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Humidity:</span>
|
||||
<span className="text-sm font-medium text-gray-900">45%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-green-50 rounded-lg">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ Good conditions for lawn applications today
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
14
frontend/src/pages/Equipment/Equipment.js
Normal file
14
frontend/src/pages/Equipment/Equipment.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Equipment = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Equipment</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Equipment management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Equipment;
|
||||
46
frontend/src/pages/Error/NotFound.js
Normal file
46
frontend/src/pages/Error/NotFound.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HomeIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div>
|
||||
<h1 className="text-9xl font-bold text-primary-600">404</h1>
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">
|
||||
Page not found
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Sorry, we couldn't find the page you're looking for.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
<HomeIcon className="h-5 w-5 mr-2" />
|
||||
Go back home
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/properties"
|
||||
className="btn-outline w-full justify-center"
|
||||
>
|
||||
View Properties
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-xs text-gray-500">
|
||||
If you believe this is an error, please contact support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
48
frontend/src/pages/Error/Unauthorized.js
Normal file
48
frontend/src/pages/Error/Unauthorized.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShieldExclamationIcon, HomeIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const Unauthorized = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div>
|
||||
<div className="mx-auto h-24 w-24 bg-red-100 rounded-full flex items-center justify-center mb-6">
|
||||
<ShieldExclamationIcon className="h-12 w-12 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
You don't have permission to access this page. Contact your administrator if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
<HomeIcon className="h-5 w-5 mr-2" />
|
||||
Go back home
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/profile"
|
||||
className="btn-outline w-full justify-center"
|
||||
>
|
||||
View Profile
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-xs text-gray-500">
|
||||
Need help? Contact support for assistance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Unauthorized;
|
||||
14
frontend/src/pages/History/History.js
Normal file
14
frontend/src/pages/History/History.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const History = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">History</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application history coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default History;
|
||||
14
frontend/src/pages/Products/Products.js
Normal file
14
frontend/src/pages/Products/Products.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Products = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Products</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Product management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
14
frontend/src/pages/Profile/Profile.js
Normal file
14
frontend/src/pages/Profile/Profile.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Profile = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Profile</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Profile management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
14
frontend/src/pages/Properties/Properties.js
Normal file
14
frontend/src/pages/Properties/Properties.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Properties = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Properties</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Property management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Properties;
|
||||
17
frontend/src/pages/Properties/PropertyDetail.js
Normal file
17
frontend/src/pages/Properties/PropertyDetail.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
const PropertyDetail = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Property Details</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Property {id} details coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyDetail;
|
||||
14
frontend/src/pages/Weather/Weather.js
Normal file
14
frontend/src/pages/Weather/Weather.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Weather = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Weather</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Weather information coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Weather;
|
||||
177
frontend/src/services/api.js
Normal file
177
frontend/src/services/api.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Base API configuration
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||
|
||||
// Create axios instance
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle specific error codes
|
||||
if (error.response?.status === 401) {
|
||||
// Unauthorized - clear token and redirect to login
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login';
|
||||
} else if (error.response?.status === 403) {
|
||||
// Forbidden
|
||||
toast.error('You do not have permission to perform this action');
|
||||
} else if (error.response?.status >= 500) {
|
||||
// Server error
|
||||
toast.error('Server error. Please try again later.');
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
// Timeout
|
||||
toast.error('Request timeout. Please check your connection.');
|
||||
} else if (!error.response) {
|
||||
// Network error
|
||||
toast.error('Network error. Please check your connection.');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API endpoints
|
||||
export const authAPI = {
|
||||
login: (credentials) => apiClient.post('/auth/login', credentials),
|
||||
register: (userData) => apiClient.post('/auth/register', userData),
|
||||
getCurrentUser: () => apiClient.get('/auth/me'),
|
||||
changePassword: (passwordData) => apiClient.post('/auth/change-password', passwordData),
|
||||
forgotPassword: (email) => apiClient.post('/auth/forgot-password', { email }),
|
||||
};
|
||||
|
||||
// Users API endpoints
|
||||
export const usersAPI = {
|
||||
getProfile: () => apiClient.get('/users/profile'),
|
||||
updateProfile: (userData) => apiClient.put('/users/profile', userData),
|
||||
deleteAccount: () => apiClient.delete('/users/account'),
|
||||
getStats: () => apiClient.get('/users/stats'),
|
||||
};
|
||||
|
||||
// Properties API endpoints
|
||||
export const propertiesAPI = {
|
||||
getAll: () => apiClient.get('/properties'),
|
||||
getById: (id) => apiClient.get(`/properties/${id}`),
|
||||
create: (propertyData) => apiClient.post('/properties', propertyData),
|
||||
update: (id, propertyData) => apiClient.put(`/properties/${id}`, propertyData),
|
||||
delete: (id) => apiClient.delete(`/properties/${id}`),
|
||||
|
||||
// Lawn sections
|
||||
createSection: (propertyId, sectionData) =>
|
||||
apiClient.post(`/properties/${propertyId}/sections`, sectionData),
|
||||
updateSection: (propertyId, sectionId, sectionData) =>
|
||||
apiClient.put(`/properties/${propertyId}/sections/${sectionId}`, sectionData),
|
||||
deleteSection: (propertyId, sectionId) =>
|
||||
apiClient.delete(`/properties/${propertyId}/sections/${sectionId}`),
|
||||
};
|
||||
|
||||
// Equipment API endpoints
|
||||
export const equipmentAPI = {
|
||||
getAll: () => apiClient.get('/equipment'),
|
||||
getById: (id) => apiClient.get(`/equipment/${id}`),
|
||||
create: (equipmentData) => apiClient.post('/equipment', equipmentData),
|
||||
update: (id, equipmentData) => apiClient.put(`/equipment/${id}`, equipmentData),
|
||||
delete: (id) => apiClient.delete(`/equipment/${id}`),
|
||||
getTypes: () => apiClient.get('/equipment/types'),
|
||||
getCalculations: (id, params) => apiClient.get(`/equipment/${id}/calculations`, { params }),
|
||||
};
|
||||
|
||||
// Products API endpoints
|
||||
export const productsAPI = {
|
||||
getAll: (params) => apiClient.get('/products', { params }),
|
||||
getById: (id) => apiClient.get(`/products/${id}`),
|
||||
search: (params) => apiClient.get('/products/search', { params }),
|
||||
getCategories: () => apiClient.get('/products/categories'),
|
||||
|
||||
// User products
|
||||
getUserProducts: () => apiClient.get('/products/user'),
|
||||
createUserProduct: (productData) => apiClient.post('/products/user', productData),
|
||||
getUserProduct: (id) => apiClient.get(`/products/user/${id}`),
|
||||
updateUserProduct: (id, productData) => apiClient.put(`/products/user/${id}`, productData),
|
||||
deleteUserProduct: (id) => apiClient.delete(`/products/user/${id}`),
|
||||
};
|
||||
|
||||
// Applications API endpoints
|
||||
export const applicationsAPI = {
|
||||
// Plans
|
||||
getPlans: (params) => apiClient.get('/applications/plans', { params }),
|
||||
getPlan: (id) => apiClient.get(`/applications/plans/${id}`),
|
||||
createPlan: (planData) => apiClient.post('/applications/plans', planData),
|
||||
updatePlanStatus: (id, status) => apiClient.put(`/applications/plans/${id}/status`, { status }),
|
||||
|
||||
// Logs
|
||||
getLogs: (params) => apiClient.get('/applications/logs', { params }),
|
||||
createLog: (logData) => apiClient.post('/applications/logs', logData),
|
||||
|
||||
// Stats
|
||||
getStats: (params) => apiClient.get('/applications/stats', { params }),
|
||||
};
|
||||
|
||||
// Weather API endpoints
|
||||
export const weatherAPI = {
|
||||
getCurrent: (propertyId) => apiClient.get(`/weather/${propertyId}`),
|
||||
getForecast: (propertyId) => apiClient.get(`/weather/${propertyId}/forecast`),
|
||||
getHistory: (propertyId, params) => apiClient.get(`/weather/${propertyId}/history`, { params }),
|
||||
checkSuitability: (propertyId, params) =>
|
||||
apiClient.get(`/weather/conditions/suitable/${propertyId}`, { params }),
|
||||
};
|
||||
|
||||
// Admin API endpoints
|
||||
export const adminAPI = {
|
||||
getDashboard: () => apiClient.get('/admin/dashboard'),
|
||||
|
||||
// Users management
|
||||
getUsers: (params) => apiClient.get('/admin/users', { params }),
|
||||
updateUserRole: (id, role) => apiClient.put(`/admin/users/${id}/role`, { role }),
|
||||
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`),
|
||||
|
||||
// Products management
|
||||
getProducts: (params) => apiClient.get('/admin/products', { params }),
|
||||
createProduct: (productData) => apiClient.post('/admin/products', productData),
|
||||
updateProduct: (id, productData) => apiClient.put(`/admin/products/${id}`, productData),
|
||||
deleteProduct: (id) => apiClient.delete(`/admin/products/${id}`),
|
||||
|
||||
// System health
|
||||
getSystemHealth: () => apiClient.get('/admin/system/health'),
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
export const handleApiError = (error, defaultMessage = 'An error occurred') => {
|
||||
if (error.response?.data?.message) {
|
||||
return error.response.data.message;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return defaultMessage;
|
||||
};
|
||||
|
||||
export const formatApiResponse = (response) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Export the configured axios instance for custom requests
|
||||
export default apiClient;
|
||||
Reference in New Issue
Block a user