Initial Claude Run

This commit is contained in:
Jake Kasper
2025-08-21 07:06:36 -05:00
parent 5ead64afcd
commit 2a46f7261e
53 changed files with 7633 additions and 2 deletions

327
frontend/src/App.js Normal file
View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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
View 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
View 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);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;