Initial Commit

This commit is contained in:
Eric Gullickson
2025-09-17 16:09:15 -05:00
parent 0cdb9803de
commit a052040e3a
373 changed files with 437090 additions and 6773 deletions

View File

@@ -2,7 +2,7 @@
* @ai-summary Main app component with routing and mobile navigation
*/
import { useState, useEffect, useTransition, lazy } from 'react';
import { useState, useEffect, useTransition, useCallback, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { motion, AnimatePresence } from 'framer-motion';
@@ -14,9 +14,13 @@ import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRound
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import { md3Theme } from './shared-minimal/theme/md3Theme';
import { Layout } from './components/Layout';
import { UnitsProvider } from './core/units/UnitsContext';
// Lazy load route components for better initial bundle size
const VehiclesPage = lazy(() => import('./features/vehicles/pages/VehiclesPage').then(m => ({ default: m.VehiclesPage })));
const VehicleDetailPage = lazy(() => import('./features/vehicles/pages/VehicleDetailPage').then(m => ({ default: m.VehicleDetailPage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage').then(m => ({ default: m.FuelLogsPage })));
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
@@ -24,26 +28,53 @@ import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
import { Button } from './shared-minimal/components/Button';
import { RouteSuspense } from './components/SuspenseWrappers';
import { Vehicle } from './features/vehicles/types/vehicles.types';
import { FuelLogForm } from './features/fuel-logs/components/FuelLogForm';
import { FuelLogsList } from './features/fuel-logs/components/FuelLogsList';
import { useFuelLogs } from './features/fuel-logs/hooks/useFuelLogs';
import { VehicleForm } from './features/vehicles/components/VehicleForm';
import { useOptimisticVehicles } from './features/vehicles/hooks/useOptimisticVehicles';
import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types';
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
import { useNavigationStore, useUserStore } from './core/store';
import { useDataSync } from './core/hooks/useDataSync';
import { MobileDebugPanel } from './core/debug/MobileDebugPanel';
import { MobileErrorBoundary } from './core/error-boundaries/MobileErrorBoundary';
function App() {
const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0();
const { isLoading, isAuthenticated, loginWithRedirect, user } = useAuth0();
const [_isPending, startTransition] = useTransition();
// Mobile navigation state - detect mobile screen size with responsive updates
// Initialize data synchronization
const { prefetchForNavigation } = useDataSync();
// Enhanced navigation and user state management
const {
activeScreen,
vehicleSubScreen,
navigateToScreen,
navigateToVehicleSubScreen,
goBack,
canGoBack,
} = useNavigationStore();
const { setUserProfile } = useUserStore();
// Mobile mode detection - detect mobile screen size with responsive updates
const [mobileMode, setMobileMode] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth <= 768;
}
return false;
});
const [activeScreen, setActiveScreen] = useState("Vehicles");
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
const [showAddVehicle, setShowAddVehicle] = useState(false);
// Update mobile mode on window resize
useEffect(() => {
const checkMobileMode = () => {
const isMobile = window.innerWidth <= 768 ||
const isMobile = window.innerWidth <= 768 ||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
console.log('Window width:', window.innerWidth, 'User agent mobile:', /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent), 'Mobile mode:', isMobile);
setMobileMode(isMobile);
@@ -51,11 +82,35 @@ function App() {
// Check on mount
checkMobileMode();
window.addEventListener('resize', checkMobileMode);
return () => window.removeEventListener('resize', checkMobileMode);
}, []);
// Update user profile when authenticated
useEffect(() => {
if (isAuthenticated && user) {
setUserProfile(user);
}
}, [isAuthenticated, user, setUserProfile]);
// Handle mobile back button and navigation errors
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
event.preventDefault();
if (canGoBack() && mobileMode) {
goBack();
}
};
if (mobileMode) {
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}
return undefined;
}, [goBack, canGoBack, mobileMode]);
// Mobile navigation items
const mobileNavItems: NavigationItem[] = [
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
@@ -64,13 +119,33 @@ function App() {
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
];
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, userAgent: navigator.userAgent });
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
// Debug component for testing
// Enhanced navigation handlers for mobile
const handleVehicleSelect = useCallback((vehicle: Vehicle) => {
setSelectedVehicle(vehicle);
navigateToVehicleSubScreen('detail', vehicle.id, { source: 'vehicle-list' });
}, [navigateToVehicleSubScreen]);
const handleAddVehicle = useCallback(() => {
setShowAddVehicle(true);
navigateToVehicleSubScreen('add', undefined, { source: 'vehicle-list' });
}, [navigateToVehicleSubScreen]);
const handleBackToList = useCallback(() => {
setSelectedVehicle(null);
setShowAddVehicle(false);
navigateToVehicleSubScreen('list', undefined, { source: 'back-navigation' });
}, [navigateToVehicleSubScreen]);
const handleVehicleAdded = useCallback(() => {
setShowAddVehicle(false);
navigateToVehicleSubScreen('list', undefined, { source: 'vehicle-added' });
}, [navigateToVehicleSubScreen]);
// Enhanced debug component
const DebugInfo = () => (
<div className="fixed bottom-0 right-0 bg-black/80 text-white p-2 text-xs z-50 rounded-tl-lg">
Mode: {mobileMode ? 'Mobile' : 'Desktop'} | Auth: {isAuthenticated ? 'Yes' : 'No'} | Screen: {typeof window !== 'undefined' ? window.innerWidth : 'N/A'}px
</div>
<MobileDebugPanel visible={import.meta.env.MODE === 'development'} />
);
// Placeholder screens for mobile
@@ -85,27 +160,85 @@ function App() {
</div>
);
const LogFuelScreen = () => (
<div className="space-y-4">
<GlassCard>
<div className="text-center py-12">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Log Fuel</h2>
<p className="text-slate-500">Coming soon - Fuel logging functionality</p>
</div>
</GlassCard>
</div>
);
const LogFuelScreen = () => {
const { fuelLogs, isLoading, error } = useFuelLogs();
const SettingsScreen = () => (
<div className="space-y-4">
<GlassCard>
<div className="text-center py-12">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Settings</h2>
<p className="text-slate-500">Coming soon - App settings and preferences</p>
if (error) {
return (
<div className="space-y-4">
<GlassCard>
<div className="text-center py-8">
<p className="text-red-600 mb-4">Failed to load fuel logs</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Retry
</button>
</div>
</GlassCard>
</div>
</GlassCard>
</div>
);
);
}
return (
<div className="space-y-4">
<FuelLogForm />
<GlassCard>
<div className="py-2">
{isLoading ? (
<div className="text-center py-8 text-slate-500">
Loading fuel logs...
</div>
) : (
<FuelLogsList logs={fuelLogs || []} />
)}
</div>
</GlassCard>
</div>
);
};
// Mobile settings now uses the dedicated MobileSettingsScreen component
const SettingsScreen = MobileSettingsScreen;
const AddVehicleScreen = () => {
// Vehicle creation logic
const { optimisticCreateVehicle } = useOptimisticVehicles([]);
const handleCreateVehicle = async (data: CreateVehicleRequest) => {
try {
await optimisticCreateVehicle(data);
// Success - navigate back to list
handleVehicleAdded();
} catch (error) {
console.error('Failed to create vehicle:', error);
// Error handling is done by the useOptimisticVehicles hook via toast
}
};
return (
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-800">Add Vehicle</h2>
<button
onClick={handleBackToList}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
</button>
</div>
<VehicleForm
onSubmit={handleCreateVehicle}
onCancel={handleBackToList}
/>
</div>
</GlassCard>
</div>
);
};
if (isLoading) {
if (mobileMode) {
@@ -175,50 +308,99 @@ function App() {
return (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<Layout mobileMode={true}>
<AnimatePresence mode="wait">
<UnitsProvider>
<Layout mobileMode={true}>
<AnimatePresence mode="popLayout" initial={false}>
{activeScreen === "Dashboard" && (
<motion.div key="dashboard" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
<DashboardScreen />
<motion.div
key="dashboard"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Dashboard">
<DashboardScreen />
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Vehicles" && (
<motion.div key="vehicles" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}} className="space-y-6">
{selectedVehicle ? (
<VehicleDetailMobile
vehicle={selectedVehicle}
onBack={() => setSelectedVehicle(null)}
onLogFuel={() => setActiveScreen("Log Fuel")}
/>
) : (
<VehiclesMobileScreen
onVehicleSelect={(vehicle) => setSelectedVehicle(vehicle)}
/>
)}
<motion.div
key="vehicles"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
className="space-y-6"
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Vehicles">
{vehicleSubScreen === 'add' || showAddVehicle ? (
<AddVehicleScreen />
) : selectedVehicle && (vehicleSubScreen === 'detail') ? (
<VehicleDetailMobile
vehicle={selectedVehicle}
onBack={handleBackToList}
onLogFuel={() => navigateToScreen("Log Fuel")}
/>
) : (
<VehiclesMobileScreen
onVehicleSelect={handleVehicleSelect}
onAddVehicle={handleAddVehicle}
/>
)}
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Log Fuel" && (
<motion.div key="logfuel" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
<LogFuelScreen />
<motion.div
key="logfuel"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Log Fuel">
<LogFuelScreen />
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Settings" && (
<motion.div key="settings" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
<SettingsScreen />
<motion.div
key="settings"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Settings">
<SettingsScreen />
</MobileErrorBoundary>
</motion.div>
)}
</AnimatePresence>
<DebugInfo />
</Layout>
<BottomNavigation
<BottomNavigation
items={mobileNavItems}
activeItem={activeScreen}
onItemSelect={(screen) => startTransition(() => {
setActiveScreen(screen);
setSelectedVehicle(null); // Reset selected vehicle on navigation
// Prefetch data for the target screen
prefetchForNavigation(screen);
// Reset states first, then navigate to prevent race conditions
if (screen !== 'Vehicles') {
setSelectedVehicle(null); // Reset selected vehicle when leaving Vehicles
}
if (screen !== 'Vehicles' || vehicleSubScreen !== 'add') {
setShowAddVehicle(false); // Reset add vehicle form when appropriate
}
// Navigate after state cleanup
navigateToScreen(screen as any, { source: 'bottom-navigation' });
})}
/>
</UnitsProvider>
</ThemeProvider>
);
}
@@ -227,22 +409,26 @@ function App() {
return (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<Layout mobileMode={false}>
<UnitsProvider>
<Layout mobileMode={false}>
<RouteSuspense>
<Routes>
<Route path="/" element={<Navigate to="/vehicles" replace />} />
<Route path="/callback" element={<div>Processing login...</div>} />
<Route path="/vehicles" element={<VehiclesPage />} />
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
<Route path="/vehicles/:id" element={<VehicleDetailPage />} />
<Route path="/fuel-logs" element={<FuelLogsPage />} />
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/vehicles" replace />} />
</Routes>
</RouteSuspense>
<DebugInfo />
</Layout>
</UnitsProvider>
</ThemeProvider>
);
}
export default App;
export default App;

View File

@@ -11,6 +11,7 @@ import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import { useAppStore } from '../core/store';
@@ -32,6 +33,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
{ name: 'Fuel Logs', href: '/fuel-logs', icon: <LocalGasStationRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Maintenance', href: '/maintenance', icon: <BuildRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Gas Stations', href: '/stations', icon: <PlaceRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Settings', href: '/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
];
// Mobile layout
@@ -83,21 +85,29 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
height: '100vh',
width: 256,
zIndex: 1000,
borderRadius: 0,
borderRight: 1,
borderColor: 'divider',
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.2s ease-in-out',
display: 'flex',
flexDirection: 'column'
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: 64,
px: 3,
borderBottom: 1,
borderColor: 'divider'
}}>
<Paper
elevation={0}
square
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: 64,
px: 3,
borderBottom: 1,
borderColor: 'divider',
borderRadius: 0
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'primary.main' }}>
MotoVaultPro
</Typography>
@@ -108,7 +118,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
>
<CloseIcon />
</IconButton>
</Box>
</Paper>
<Box sx={{ mt: 3, px: 2, flex: 1 }}>
{navigation.map((item) => {
@@ -241,4 +251,4 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
)}
</Box>
);
};
};

View File

@@ -16,10 +16,20 @@ export const apiClient: AxiosInstance = axios.create({
},
});
// Request interceptor for auth token
// Request interceptor for auth token with mobile debugging
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Token will be added by Auth0 wrapper
// Log mobile requests for debugging
if (import.meta.env.MODE === 'development') {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile && config.url?.includes('/vehicles')) {
console.log('Mobile API request:', config.method?.toUpperCase(), config.url, {
hasAuth: !!config.headers.Authorization,
authPreview: config.headers.Authorization?.toString().substring(0, 20) + '...'
});
}
}
return config;
},
(error) => {
@@ -27,19 +37,37 @@ apiClient.interceptors.request.use(
}
);
// Response interceptor for error handling
// Response interceptor for error handling with mobile-specific logic
apiClient.interceptors.response.use(
(response) => response,
(error) => {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (error.response?.status === 401) {
// Handle unauthorized - Auth0 will redirect to login
toast.error('Session expired. Please login again.');
// Enhanced 401 handling for mobile token issues
const errorMessage = error.response?.data?.message || '';
const isTokenIssue = errorMessage.includes('token') || errorMessage.includes('JWT') || errorMessage.includes('Unauthorized');
if (isMobile && isTokenIssue) {
// Mobile devices sometimes have token timing issues
// Show a more helpful message that doesn't sound like a permanent error
toast.error('Refreshing your session...', {
duration: 3000,
id: 'mobile-auth-refresh' // Prevent duplicate toasts
});
} else {
// Standard session expiry message
toast.error('Session expired. Please login again.');
}
} else if (error.response?.status === 403) {
toast.error('You do not have permission to perform this action.');
} else if (error.response?.status >= 500) {
toast.error('Server error. Please try again later.');
} else if (error.code === 'NETWORK_ERROR' && isMobile) {
// Mobile-specific network error handling
toast.error('Network error. Please check your connection and try again.');
}
return Promise.reject(error);
}
);

View File

@@ -28,35 +28,102 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
domain={domain}
clientId={clientId}
authorizationParams={{
redirect_uri: window.location.origin,
redirect_uri: window.location.hostname === "admin.motovaultpro.com" ? "https://admin.motovaultpro.com/callback" : window.location.origin + "/callback",
audience: audience,
}}
onRedirectCallback={onRedirectCallback}
cacheLocation="localstorage"
useRefreshTokens={true}
>
<TokenInjector>{children}</TokenInjector>
</BaseAuth0Provider>
);
};
// Component to inject token into API client
// Component to inject token into API client with mobile support
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
const [retryCount, setRetryCount] = React.useState(0);
// Helper function to get token with retry logic for mobile devices
const getTokenWithRetry = async (maxRetries = 3, delayMs = 500): Promise<string | null> => {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Progressive fallback strategy for mobile compatibility
let tokenOptions;
if (attempt === 0) {
// First attempt: try cache first
tokenOptions = { timeoutInSeconds: 15, cacheMode: 'on' as const };
} else if (attempt === 1) {
// Second attempt: force refresh
tokenOptions = { timeoutInSeconds: 20, cacheMode: 'off' as const };
} else {
// Final attempt: default behavior with longer timeout
tokenOptions = { timeoutInSeconds: 30 };
}
const token = await getAccessTokenSilently(tokenOptions);
console.log(`Token acquired successfully on attempt ${attempt + 1}`);
return token;
} catch (error: any) {
console.warn(`Token acquisition attempt ${attempt + 1} failed:`, error.message || error);
// On mobile, Auth0 might need more time - wait and retry
if (attempt < maxRetries - 1) {
const delay = delayMs * Math.pow(2, attempt); // Exponential backoff
console.log(`Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
console.error('All token acquisition attempts failed');
return null;
};
React.useEffect(() => {
let interceptorId: number | undefined;
if (isAuthenticated) {
// Add token to all API requests
// Pre-warm token cache for mobile devices with delay
const initializeToken = async () => {
// Give Auth0 a moment to fully initialize on mobile
await new Promise(resolve => setTimeout(resolve, 100));
try {
const token = await getTokenWithRetry();
if (token) {
console.log('Token pre-warming successful');
setRetryCount(0);
} else {
console.error('Failed to acquire token after retries - will retry on API calls');
setRetryCount(prev => prev + 1);
}
} catch (error) {
console.error('Token initialization failed:', error);
setRetryCount(prev => prev + 1);
}
};
initializeToken();
// Add token to all API requests with enhanced error handling
interceptorId = apiClient.interceptors.request.use(async (config) => {
try {
const token = await getAccessTokenSilently();
config.headers.Authorization = `Bearer ${token}`;
} catch (error) {
console.error('Failed to get access token:', error);
const token = await getTokenWithRetry();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
console.error('No token available for request to:', config.url);
// Allow request to proceed - backend will return 401 if needed
}
} catch (error: any) {
console.error('Failed to get access token for request:', error.message || error);
// Allow request to proceed - backend will return 401 if needed
}
return config;
});
} else {
setRetryCount(0);
}
// Cleanup function to remove interceptor
@@ -65,7 +132,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
apiClient.interceptors.request.eject(interceptorId);
}
};
}, [isAuthenticated, getAccessTokenSilently]);
}, [isAuthenticated, getAccessTokenSilently, retryCount]);
return <>{children}</>;
};

View File

@@ -0,0 +1,138 @@
/**
* @ai-summary Auth0 provider wrapper with API token injection
*/
import React from 'react';
import { Auth0Provider as BaseAuth0Provider, useAuth0 } from '@auth0/auth0-react';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../api/client';
interface Auth0ProviderProps {
children: React.ReactNode;
}
export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
const navigate = useNavigate();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
const onRedirectCallback = (appState?: { returnTo?: string }) => {
navigate(appState?.returnTo || '/dashboard');
};
return (
<BaseAuth0Provider
domain={domain}
clientId={clientId}
authorizationParams={{
redirect_uri: window.location.origin,
audience: audience,
}}
onRedirectCallback={onRedirectCallback}
cacheLocation="localstorage"
useRefreshTokens={true}
>
<TokenInjector>{children}</TokenInjector>
</BaseAuth0Provider>
);
};
// Component to inject token into API client with mobile support
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
const [retryCount, setRetryCount] = React.useState(0);
// Helper function to get token with retry logic for mobile devices
const getTokenWithRetry = async (maxRetries = 3, delayMs = 500): Promise<string | null> => {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
// Progressive fallback strategy for mobile compatibility
let tokenOptions;
if (attempt === 0) {
// First attempt: try cache first
tokenOptions = { timeoutInSeconds: 15, cacheMode: 'on' as const };
} else if (attempt === 1) {
// Second attempt: force refresh
tokenOptions = { timeoutInSeconds: 20, cacheMode: 'off' as const };
} else {
// Final attempt: default behavior with longer timeout
tokenOptions = { timeoutInSeconds: 30 };
}
const token = await getAccessTokenSilently(tokenOptions);
console.log(`Token acquired successfully on attempt ${attempt + 1}`);
return token;
} catch (error: any) {
console.warn(`Token acquisition attempt ${attempt + 1} failed:`, error.message || error);
// On mobile, Auth0 might need more time - wait and retry
if (attempt < maxRetries - 1) {
const delay = delayMs * Math.pow(2, attempt); // Exponential backoff
console.log(`Waiting ${delay}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
console.error('All token acquisition attempts failed');
return null;
};
React.useEffect(() => {
let interceptorId: number | undefined;
if (isAuthenticated) {
// Pre-warm token cache for mobile devices with delay
const initializeToken = async () => {
// Give Auth0 a moment to fully initialize on mobile
await new Promise(resolve => setTimeout(resolve, 100));
try {
const token = await getTokenWithRetry();
if (token) {
console.log('Token pre-warming successful');
setRetryCount(0);
} else {
console.error('Failed to acquire token after retries - will retry on API calls');
setRetryCount(prev => prev + 1);
}
} catch (error) {
console.error('Token initialization failed:', error);
setRetryCount(prev => prev + 1);
}
};
initializeToken();
// Add token to all API requests with enhanced error handling
interceptorId = apiClient.interceptors.request.use(async (config) => {
try {
const token = await getTokenWithRetry();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
console.error('No token available for request to:', config.url);
// Allow request to proceed - backend will return 401 if needed
}
} catch (error: any) {
console.error('Failed to get access token for request:', error.message || error);
// Allow request to proceed - backend will return 401 if needed
}
return config;
});
} else {
setRetryCount(0);
}
// Cleanup function to remove interceptor
return () => {
if (interceptorId !== undefined) {
apiClient.interceptors.request.eject(interceptorId);
}
};
}, [isAuthenticated, getAccessTokenSilently, retryCount]);
return <>{children}</>;
};

View File

@@ -0,0 +1,258 @@
/**
* @ai-summary Enhanced debugging panel for mobile token flow and performance monitoring
*/
import React, { useState, useEffect } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigationStore, useUserStore } from '../store';
interface DebugInfo {
timestamp: string;
type: 'auth' | 'query' | 'navigation' | 'network';
message: string;
data?: any;
}
export const MobileDebugPanel: React.FC<{ visible: boolean }> = ({ visible }) => {
const { isAuthenticated, getAccessTokenSilently } = useAuth0();
const queryClient = useQueryClient();
const navigationStore = useNavigationStore();
const userStore = useUserStore();
const [debugLogs, setDebugLogs] = useState<DebugInfo[]>([]);
const [tokenInfo, setTokenInfo] = useState<{
hasToken: boolean;
tokenPreview?: string;
lastRefresh?: string;
cacheMode?: string;
}>({
hasToken: false,
});
const [expanded, setExpanded] = useState(false);
// Monitor token status
useEffect(() => {
if (!isAuthenticated) return;
const checkToken = async () => {
try {
const token = await getAccessTokenSilently({ cacheMode: 'cache-only' });
setTokenInfo({
hasToken: !!token,
tokenPreview: token ? token.substring(0, 20) + '...' : undefined,
lastRefresh: new Date().toLocaleTimeString(),
cacheMode: 'cache-only',
});
addDebugLog('auth', 'Token check successful', {
hasToken: !!token,
cacheMode: 'cache-only',
});
} catch (error) {
addDebugLog('auth', 'Token check failed', { error: error instanceof Error ? error.message : 'Unknown error' });
setTokenInfo({ hasToken: false });
}
};
checkToken();
const interval = setInterval(checkToken, 10000); // Check every 10 seconds
return () => clearInterval(interval);
}, [isAuthenticated, getAccessTokenSilently]);
const addDebugLog = (type: DebugInfo['type'], message: string, data?: any) => {
const logEntry: DebugInfo = {
timestamp: new Date().toLocaleTimeString(),
type,
message,
data,
};
setDebugLogs(prev => [...prev.slice(-19), logEntry]); // Keep last 20 entries
};
// Monitor navigation changes
useEffect(() => {
addDebugLog('navigation', `Navigated to ${navigationStore.activeScreen}`, {
screen: navigationStore.activeScreen,
subScreen: navigationStore.vehicleSubScreen,
selectedVehicleId: navigationStore.selectedVehicleId,
isNavigating: navigationStore.isNavigating,
historyLength: navigationStore.navigationHistory.length,
});
}, [navigationStore.activeScreen, navigationStore.vehicleSubScreen, navigationStore.selectedVehicleId, navigationStore.isNavigating]);
// Monitor navigation errors
useEffect(() => {
if (navigationStore.navigationError) {
addDebugLog('navigation', `Navigation Error: ${navigationStore.navigationError}`, {
error: navigationStore.navigationError,
screen: navigationStore.activeScreen,
});
}
}, [navigationStore.navigationError]);
// Monitor network status
useEffect(() => {
const handleOnline = () => addDebugLog('network', 'Network: Online');
const handleOffline = () => addDebugLog('network', 'Network: Offline');
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const getQueryCacheStats = () => {
const cache = queryClient.getQueryCache();
const queries = cache.getAll();
return {
total: queries.length,
stale: queries.filter(q => q.isStale()).length,
loading: queries.filter(q => q.state.status === 'pending').length,
error: queries.filter(q => q.state.status === 'error').length,
};
};
const testTokenRefresh = async () => {
try {
addDebugLog('auth', 'Testing token refresh...');
const token = await getAccessTokenSilently({ cacheMode: 'off' });
addDebugLog('auth', 'Token refresh successful', {
hasToken: !!token,
length: token?.length,
});
setTokenInfo(prev => ({
...prev,
hasToken: !!token,
tokenPreview: token ? token.substring(0, 20) + '...' : undefined,
lastRefresh: new Date().toLocaleTimeString(),
cacheMode: 'off',
}));
} catch (error) {
addDebugLog('auth', 'Token refresh failed', {
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
if (!visible) return null;
const cacheStats = getQueryCacheStats();
const isMobile = window.innerWidth <= 768;
return (
<div className={`fixed ${expanded ? 'inset-4' : 'bottom-4 right-4'} bg-black/90 text-white text-xs font-mono rounded-lg transition-all duration-300 z-50`}>
<div className="flex items-center justify-between p-2 border-b border-white/20">
<span className="font-semibold">Debug Panel</span>
<button
onClick={() => setExpanded(!expanded)}
className="text-white/70 hover:text-white"
>
{expanded ? '' : '+'}
</button>
</div>
{expanded ? (
<div className="p-3 max-h-[80vh] overflow-y-auto">
{/* System Info */}
<div className="mb-4">
<div className="text-yellow-400 font-semibold mb-1">System Status</div>
<div>Mode: {isMobile ? 'Mobile' : 'Desktop'} | Auth: {isAuthenticated ? 'Yes' : 'No'}</div>
<div>Screen: {navigationStore.activeScreen} | Sub: {navigationStore.vehicleSubScreen}</div>
<div>Online: {userStore.isOnline ? 'Yes' : 'No'} | Width: {window.innerWidth}px</div>
</div>
{/* Navigation Info */}
<div className="mb-4">
<div className="text-cyan-400 font-semibold mb-1">Navigation State</div>
<div>Current: {navigationStore.activeScreen} {navigationStore.vehicleSubScreen}</div>
<div>Navigating: {navigationStore.isNavigating ? 'Yes' : 'No'}</div>
<div>History: {navigationStore.navigationHistory.length} entries</div>
<div>Selected Vehicle: {navigationStore.selectedVehicleId || 'None'}</div>
{navigationStore.navigationError && (
<div className="text-red-300">Error: {navigationStore.navigationError}</div>
)}
<div className="mt-2 flex gap-1 flex-wrap">
{['Dashboard', 'Vehicles', 'Log Fuel', 'Settings'].map((screen) => (
<button
key={screen}
onClick={() => {
addDebugLog('navigation', `Debug navigation to ${screen}`);
navigationStore.navigateToScreen(screen as any);
}}
className={`text-xs px-2 py-1 rounded ${
navigationStore.activeScreen === screen
? 'bg-cyan-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{screen.slice(0, 3)}
</button>
))}
</div>
</div>
{/* Token Info */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<span className="text-green-400 font-semibold">Token Status</span>
<button
onClick={testTokenRefresh}
className="text-xs bg-blue-600 px-2 py-1 rounded hover:bg-blue-700"
>
Test Refresh
</button>
</div>
<div>Has Token: {tokenInfo.hasToken ? 'Yes' : 'No'}</div>
{tokenInfo.tokenPreview && <div>Preview: {tokenInfo.tokenPreview}</div>}
{tokenInfo.lastRefresh && <div>Last Refresh: {tokenInfo.lastRefresh}</div>}
{tokenInfo.cacheMode && <div>Cache Mode: {tokenInfo.cacheMode}</div>}
</div>
{/* Query Cache Stats */}
<div className="mb-4">
<div className="text-blue-400 font-semibold mb-1">Query Cache</div>
<div>Total: {cacheStats.total} | Stale: {cacheStats.stale}</div>
<div>Loading: {cacheStats.loading} | Error: {cacheStats.error}</div>
</div>
{/* Debug Logs */}
<div>
<div className="text-purple-400 font-semibold mb-1">Recent Events</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{debugLogs.slice(-10).reverse().map((log, index) => (
<div key={index} className="text-xs">
<span className="text-white/50">[{log.timestamp}]</span>{' '}
<span className={
log.type === 'auth' ? 'text-green-300' :
log.type === 'query' ? 'text-blue-300' :
log.type === 'navigation' ? 'text-yellow-300' :
'text-purple-300'
}>
{log.type.toUpperCase()}
</span>:{' '}
<span>{log.message}</span>
</div>
))}
</div>
</div>
</div>
) : (
<div className="p-2">
<div>
{isMobile ? 'M' : 'D'} | {isAuthenticated ? '🔐' : '🔓'} |
{navigationStore.activeScreen.substring(0, 3)} |
T:{tokenInfo.hasToken ? '✅' : '❌'}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,124 @@
/**
* @ai-summary Error boundary component specifically designed for mobile screens
*/
import React from 'react';
import { GlassCard } from '../../shared-minimal/components/mobile/GlassCard';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
interface MobileErrorBoundaryProps {
children: React.ReactNode;
screenName: string;
onRetry?: () => void;
}
export class MobileErrorBoundary extends React.Component<MobileErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: MobileErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { hasError: true, error };
}
override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.setState({
error,
errorInfo
});
// Log error for debugging
console.error(`Mobile screen error in ${this.props.screenName}:`, error, errorInfo);
}
handleRetry = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
this.props.onRetry?.();
};
override render() {
if (this.state.hasError) {
return (
<div className="space-y-4 p-4">
<GlassCard>
<div className="text-center py-8">
<div className="mb-4">
<div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
<svg
className="w-8 h-8 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">
Oops! Something went wrong
</h3>
<p className="text-slate-600 mb-4">
There was an error loading the {this.props.screenName.toLowerCase()} screen.
</p>
</div>
<div className="space-y-3">
<button
onClick={this.handleRetry}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Refresh App
</button>
</div>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="mt-6 text-left">
<summary className="text-sm text-slate-500 cursor-pointer">
Error Details (Development)
</summary>
<div className="mt-2 p-3 bg-red-50 rounded text-xs text-red-800 overflow-auto">
<div className="font-semibold mb-1">Error:</div>
<div className="mb-2">{this.state.error.message}</div>
<div className="font-semibold mb-1">Stack Trace:</div>
<pre className="whitespace-pre-wrap text-xs">
{this.state.error.stack}
</pre>
{this.state.errorInfo && (
<>
<div className="font-semibold mb-1 mt-2">Component Stack:</div>
<pre className="whitespace-pre-wrap text-xs">
{this.state.errorInfo.componentStack}
</pre>
</>
)}
</div>
</details>
)}
</div>
</GlassCard>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,44 @@
/**
* @ai-summary React hook for data synchronization management
*/
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { DataSyncManager } from '../sync/data-sync';
import { useNavigationStore } from '../store/navigation';
export const useDataSync = () => {
const queryClient = useQueryClient();
const syncManagerRef = useRef<DataSyncManager | null>(null);
const navigationStore = useNavigationStore();
useEffect(() => {
// Initialize data sync manager
syncManagerRef.current = new DataSyncManager(queryClient, {
enableCrossTabs: true,
enableOptimisticUpdates: true,
enableBackgroundSync: true,
syncInterval: 30000,
});
return () => {
syncManagerRef.current?.cleanup();
};
}, [queryClient]);
// Listen for navigation changes and trigger prefetching
useEffect(() => {
if (syncManagerRef.current) {
syncManagerRef.current.prefetchForNavigation(navigationStore.activeScreen);
}
}, [navigationStore.activeScreen]);
return {
optimisticVehicleUpdate: (vehicleId: string, updates: any) => {
syncManagerRef.current?.optimisticVehicleUpdate(vehicleId, updates);
},
prefetchForNavigation: (screen: string) => {
syncManagerRef.current?.prefetchForNavigation(screen);
},
};
};

View File

@@ -0,0 +1,175 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigationStore } from '../store/navigation';
export interface UseFormStateOptions<T> {
formId: string;
defaultValues: T;
autoSave?: boolean;
saveDelay?: number;
onRestore?: (data: T) => void;
onSave?: (data: T) => void;
validate?: (data: T) => Record<string, string> | null;
}
export interface FormStateReturn<T> {
formData: T;
updateFormData: (updates: Partial<T>) => void;
setFormData: (data: T) => void;
resetForm: () => void;
submitForm: () => Promise<void>;
hasChanges: boolean;
isRestored: boolean;
isSaving: boolean;
errors: Record<string, string>;
isValid: boolean;
}
export const useFormState = <T extends Record<string, any>>({
formId,
defaultValues,
autoSave = true,
saveDelay = 1000,
onRestore,
onSave,
validate,
}: UseFormStateOptions<T>): FormStateReturn<T> => {
const { saveFormState, restoreFormState, clearFormState } = useNavigationStore();
const [formData, setFormDataState] = useState<T>(defaultValues);
const [hasChanges, setHasChanges] = useState(false);
const [isRestored, setIsRestored] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const initialDataRef = useRef<T>(defaultValues);
const formDataRef = useRef<T>(formData);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Update ref when formData changes
useEffect(() => {
formDataRef.current = formData;
}, [formData]);
// Validation
const validateForm = useCallback((data: T) => {
if (!validate) return {};
const validationErrors = validate(data);
return validationErrors || {};
}, [validate]);
// Restore form state on mount
useEffect(() => {
const restoredState = restoreFormState(formId);
if (restoredState && !isRestored) {
const restoredData = { ...defaultValues, ...restoredState.data };
setFormDataState(restoredData);
setHasChanges(restoredState.isDirty);
setIsRestored(true);
if (onRestore) {
onRestore(restoredData);
}
}
}, [formId, restoreFormState, defaultValues, isRestored, onRestore]);
// Auto-save with debounce
useEffect(() => {
if (!autoSave || !hasChanges) return;
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Set new timeout
saveTimeoutRef.current = setTimeout(async () => {
try {
setIsSaving(true);
saveFormState(formId, formDataRef.current, hasChanges);
if (onSave) {
await onSave(formDataRef.current);
}
} catch (error) {
console.warn('Form auto-save failed:', error);
} finally {
setIsSaving(false);
}
}, saveDelay);
// Cleanup timeout
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [formData, hasChanges, autoSave, saveDelay, formId, saveFormState, onSave]);
// Validate when form data changes
useEffect(() => {
if (hasChanges) {
const validationErrors = validateForm(formData);
setErrors(validationErrors);
}
}, [formData, hasChanges, validateForm]);
const updateFormData = useCallback((updates: Partial<T>) => {
setFormDataState((current) => {
const updated = { ...current, ...updates };
const hasActualChanges = JSON.stringify(updated) !== JSON.stringify(initialDataRef.current);
setHasChanges(hasActualChanges);
return updated;
});
}, []);
const setFormData = useCallback((data: T) => {
setFormDataState(data);
const hasActualChanges = JSON.stringify(data) !== JSON.stringify(initialDataRef.current);
setHasChanges(hasActualChanges);
}, []);
const resetForm = useCallback(() => {
setFormDataState(defaultValues);
setHasChanges(false);
setErrors({});
clearFormState(formId);
initialDataRef.current = { ...defaultValues };
}, [defaultValues, formId, clearFormState]);
const submitForm = useCallback(async () => {
const validationErrors = validateForm(formDataRef.current);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) {
throw new Error('Form validation failed');
}
try {
setHasChanges(false);
clearFormState(formId);
initialDataRef.current = { ...formDataRef.current };
if (onSave) {
await onSave(formDataRef.current);
}
} catch (error) {
setHasChanges(true); // Restore changes state on error
throw error;
}
}, [validateForm, formId, clearFormState, onSave]);
const isValid = Object.keys(errors).length === 0;
return {
formData,
updateFormData,
setFormData,
resetForm,
submitForm,
hasChanges,
isRestored,
isSaving,
errors,
isValid,
};
};

View File

@@ -0,0 +1,148 @@
/**
* @ai-summary Enhanced Query Client configuration with mobile optimization
*/
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import toast from 'react-hot-toast';
// Mobile detection utility
const isMobileDevice = (): boolean => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth <= 768;
};
// Enhanced error handler for mobile devices
const handleQueryError = (error: any) => {
const isMobile = isMobileDevice();
if (error?.response?.status === 401) {
// Token refresh handled by Auth0Provider
if (isMobile) {
toast.error('Refreshing session...', {
duration: 2000,
id: 'mobile-auth-refresh'
});
}
} else if (error?.response?.status >= 500) {
toast.error(isMobile ? 'Server issue, retrying...' : 'Server error occurred', {
duration: isMobile ? 3000 : 4000,
});
} else if (error?.code === 'NETWORK_ERROR') {
if (isMobile) {
toast.error('Check connection and try again', {
duration: 4000,
id: 'mobile-network-error'
});
} else {
toast.error('Network error occurred');
}
}
};
// Create enhanced query client with mobile-optimized settings
export const createEnhancedQueryClient = (): QueryClient => {
const isMobile = isMobileDevice();
return new QueryClient({
queryCache: new QueryCache({
onError: handleQueryError,
}),
mutationCache: new MutationCache({
onError: handleQueryError,
}),
defaultOptions: {
queries: {
// Mobile-optimized retry strategy
retry: (failureCount, error: any) => {
// Don't retry 4xx errors except 401 (auth issues)
if (error?.response?.status >= 400 && error?.response?.status < 500) {
return error?.response?.status === 401 && failureCount < 2;
}
// Mobile devices get more aggressive retry for network issues
if (isMobile) {
return failureCount < 3;
}
return failureCount < 2;
},
// Mobile-optimized timing
retryDelay: (attemptIndex) => {
const baseDelay = isMobile ? 1000 : 500;
return Math.min(baseDelay * (2 ** attemptIndex), 30000);
},
// Stale time optimization for mobile
staleTime: isMobile ? 1000 * 60 * 2 : 1000 * 60 * 5, // 2 min mobile, 5 min desktop
// GC time optimization
gcTime: isMobile ? 1000 * 60 * 10 : 1000 * 60 * 30, // 10 min mobile, 30 min desktop
// Refetch behavior
refetchOnWindowFocus: !isMobile, // Disable on mobile to save data
refetchOnReconnect: true,
refetchOnMount: true,
// Network mode for offline capability
networkMode: 'offlineFirst',
},
mutations: {
// Mutation retry strategy
retry: (failureCount, error: any) => {
if (error?.response?.status >= 400 && error?.response?.status < 500) {
return false; // Don't retry 4xx errors for mutations
}
return failureCount < (isMobile ? 2 : 1);
},
retryDelay: (attemptIndex) => {
const baseDelay = isMobile ? 2000 : 1000;
return Math.min(baseDelay * (2 ** attemptIndex), 30000);
},
networkMode: 'offlineFirst',
},
},
});
};
// Query key factories for consistent cache management
export const queryKeys = {
all: ['motovaultpro'] as const,
users: () => [...queryKeys.all, 'users'] as const,
user: (id: string) => [...queryKeys.users(), id] as const,
vehicles: () => [...queryKeys.all, 'vehicles'] as const,
vehicle: (id: string) => [...queryKeys.vehicles(), id] as const,
vehiclesByUser: (userId: string) => [...queryKeys.vehicles(), 'user', userId] as const,
fuelLogs: () => [...queryKeys.all, 'fuel-logs'] as const,
fuelLog: (id: string) => [...queryKeys.fuelLogs(), id] as const,
fuelLogsByVehicle: (vehicleId: string) => [...queryKeys.fuelLogs(), 'vehicle', vehicleId] as const,
settings: () => [...queryKeys.all, 'settings'] as const,
userSettings: (userId: string) => [...queryKeys.settings(), 'user', userId] as const,
} as const;
// Performance monitoring utilities
export const queryPerformanceMonitor = {
logSlowQuery: (queryKey: readonly unknown[], duration: number) => {
if (duration > 5000) { // Log queries taking more than 5 seconds
console.warn('Slow query detected:', {
queryKey,
duration: `${duration}ms`,
isMobile: isMobileDevice(),
timestamp: new Date().toISOString(),
});
}
},
logCacheHit: (queryKey: readonly unknown[], fromCache: boolean) => {
if (import.meta.env.MODE === 'development') {
console.log('Query cache:', {
queryKey,
fromCache,
isMobile: isMobileDevice(),
});
}
},
};

View File

@@ -0,0 +1,24 @@
import { create } from 'zustand';
import { Vehicle } from '../../features/vehicles/types/vehicles.types';
interface AppState {
// UI state
sidebarOpen: boolean;
selectedVehicle: Vehicle | null;
// Actions
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSelectedVehicle: (vehicle: Vehicle | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
// Initial state
sidebarOpen: false,
selectedVehicle: null,
// Actions
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }),
}));

View File

@@ -1,54 +1,12 @@
/**
* @ai-summary Global state management with Zustand
* @ai-context Minimal global state, features manage their own state
*/
// Export navigation store
export { useNavigationStore } from './navigation';
export type { MobileScreen, VehicleSubScreen } from './navigation';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
// Export user store
export { useUserStore } from './user';
interface User {
id: string;
email: string;
name?: string;
}
// Export app store (compatibility)
export { useAppStore } from './app';
interface AppState {
// User state
user: User | null;
setUser: (user: User | null) => void;
// UI state
sidebarOpen: boolean;
toggleSidebar: () => void;
// Selected vehicle (for context)
selectedVehicleId: string | null;
setSelectedVehicle: (id: string | null) => void;
}
export const useAppStore = create<AppState>()(
devtools(
persist(
(set) => ({
// User state
user: null,
setUser: (user) => set({ user }),
// UI state
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
// Selected vehicle
selectedVehicleId: null,
setSelectedVehicle: (vehicleId) => set({ selectedVehicleId: vehicleId }),
}),
{
name: 'motovaultpro-storage',
partialize: (state) => ({
selectedVehicleId: state.selectedVehicleId,
sidebarOpen: state.sidebarOpen,
}),
}
)
)
);
// Note: This replaces any existing store exports and provides
// centralized access to all Zustand stores in the application

View File

@@ -0,0 +1,205 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Settings';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
interface NavigationHistory {
screen: MobileScreen;
vehicleSubScreen?: VehicleSubScreen;
selectedVehicleId?: string | null;
timestamp: number;
metadata?: Record<string, any>;
}
interface FormState {
data: Record<string, any>;
timestamp: number;
isDirty: boolean;
}
interface NavigationState {
// Current navigation state
activeScreen: MobileScreen;
vehicleSubScreen: VehicleSubScreen;
selectedVehicleId: string | null;
// Navigation history for back button
navigationHistory: NavigationHistory[];
// Form state preservation
formStates: Record<string, FormState>;
// Loading and error states
isNavigating: boolean;
navigationError: string | null;
// Actions
navigateToScreen: (screen: MobileScreen, metadata?: Record<string, any>) => void;
navigateToVehicleSubScreen: (subScreen: VehicleSubScreen, vehicleId?: string, metadata?: Record<string, any>) => void;
goBack: () => boolean;
canGoBack: () => boolean;
saveFormState: (formId: string, data: any, isDirty?: boolean) => void;
restoreFormState: (formId: string) => FormState | null;
clearFormState: (formId: string) => void;
clearAllFormStates: () => void;
setNavigationError: (error: string | null) => void;
}
export const useNavigationStore = create<NavigationState>()(
persist(
(set, get) => ({
// Initial state
activeScreen: 'Vehicles',
vehicleSubScreen: 'list',
selectedVehicleId: null,
navigationHistory: [],
formStates: {},
isNavigating: false,
navigationError: null,
// Navigation actions
navigateToScreen: (screen, metadata = {}) => {
const currentState = get();
// Skip navigation if already on the same screen
if (currentState.activeScreen === screen && !currentState.isNavigating) {
return;
}
try {
const historyEntry: NavigationHistory = {
screen: currentState.activeScreen,
vehicleSubScreen: currentState.vehicleSubScreen,
selectedVehicleId: currentState.selectedVehicleId,
timestamp: Date.now(),
metadata,
};
// Update state atomically to prevent blank screens
set({
activeScreen: screen,
vehicleSubScreen: screen === 'Vehicles' ? currentState.vehicleSubScreen : 'list',
selectedVehicleId: screen === 'Vehicles' ? currentState.selectedVehicleId : null,
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
isNavigating: false,
navigationError: null,
});
} catch (error) {
set({
navigationError: error instanceof Error ? error.message : 'Navigation failed',
isNavigating: false
});
}
},
navigateToVehicleSubScreen: (subScreen, vehicleId, metadata = {}) => {
const currentState = get();
set({ isNavigating: true, navigationError: null });
try {
const historyEntry: NavigationHistory = {
screen: currentState.activeScreen,
vehicleSubScreen: currentState.vehicleSubScreen,
selectedVehicleId: currentState.selectedVehicleId,
timestamp: Date.now(),
metadata,
};
set({
vehicleSubScreen: subScreen,
selectedVehicleId: vehicleId !== null ? vehicleId : currentState.selectedVehicleId,
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
isNavigating: false,
});
} catch (error) {
set({
navigationError: error instanceof Error ? error.message : 'Navigation failed',
isNavigating: false
});
}
},
goBack: () => {
const currentState = get();
const lastEntry = currentState.navigationHistory[currentState.navigationHistory.length - 1];
if (lastEntry) {
set({
activeScreen: lastEntry.screen,
vehicleSubScreen: lastEntry.vehicleSubScreen || 'list',
selectedVehicleId: lastEntry.selectedVehicleId,
navigationHistory: currentState.navigationHistory.slice(0, -1),
isNavigating: false,
navigationError: null,
});
return true;
}
return false;
},
canGoBack: () => {
return get().navigationHistory.length > 0;
},
// Form state management
saveFormState: (formId, data, isDirty = true) => {
const currentState = get();
const formState: FormState = {
data,
timestamp: Date.now(),
isDirty,
};
set({
formStates: {
...currentState.formStates,
[formId]: formState,
},
});
},
restoreFormState: (formId) => {
const state = get().formStates[formId];
const maxAge = 2 * 60 * 60 * 1000; // 2 hours
if (state && Date.now() - state.timestamp < maxAge) {
return state;
}
// Clean up old state
if (state) {
get().clearFormState(formId);
}
return null;
},
clearFormState: (formId) => {
const currentState = get();
const newFormStates = { ...currentState.formStates };
delete newFormStates[formId];
set({ formStates: newFormStates });
},
clearAllFormStates: () => {
set({ formStates: {} });
},
setNavigationError: (error) => {
set({ navigationError: error });
},
}),
{
name: 'motovaultpro-mobile-navigation',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
activeScreen: state.activeScreen,
vehicleSubScreen: state.vehicleSubScreen,
selectedVehicleId: state.selectedVehicleId,
formStates: state.formStates,
}),
}
)
);

View File

@@ -0,0 +1,101 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface UserPreferences {
unitSystem: 'imperial' | 'metric';
darkMode: boolean;
notifications: {
email: boolean;
push: boolean;
maintenance: boolean;
};
}
interface UserState {
// User data (persisted subset)
userProfile: {
id: string;
name: string;
email: string;
picture: string;
} | null;
preferences: UserPreferences;
// Session data (not persisted)
isOnline: boolean;
lastSyncTimestamp: number;
// Actions
setUserProfile: (profile: any) => void;
updatePreferences: (preferences: Partial<UserPreferences>) => void;
setOnlineStatus: (isOnline: boolean) => void;
updateLastSync: () => void;
clearUserData: () => void;
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
// Initial state
userProfile: null,
preferences: {
unitSystem: 'imperial',
darkMode: false,
notifications: {
email: true,
push: true,
maintenance: true,
},
},
isOnline: true,
lastSyncTimestamp: 0,
// Actions
setUserProfile: (profile) => {
if (profile) {
set({
userProfile: {
id: profile.sub,
name: profile.name,
email: profile.email,
picture: profile.picture,
},
});
}
},
updatePreferences: (newPreferences) => {
set((state) => ({
preferences: { ...state.preferences, ...newPreferences },
}));
},
setOnlineStatus: (isOnline) => set({ isOnline }),
updateLastSync: () => set({ lastSyncTimestamp: Date.now() }),
clearUserData: () => set({
userProfile: null,
preferences: {
unitSystem: 'imperial',
darkMode: false,
notifications: {
email: true,
push: true,
maintenance: true,
},
},
}),
}),
{
name: 'motovaultpro-user-context',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
userProfile: state.userProfile,
preferences: state.preferences,
// Don't persist session data
}),
}
)
);

View File

@@ -0,0 +1,254 @@
/**
* @ai-summary Data synchronization layer integrating React Query with Zustand stores
*/
import { QueryClient } from '@tanstack/react-query';
import { useNavigationStore } from '../store/navigation';
import { useUserStore } from '../store/user';
import { Vehicle } from '../../features/vehicles/types/vehicles.types';
interface SyncConfig {
enableCrossTabs: boolean;
enableOptimisticUpdates: boolean;
enableBackgroundSync: boolean;
syncInterval: number;
}
export class DataSyncManager {
private queryClient: QueryClient;
private config: SyncConfig;
private syncInterval?: NodeJS.Timeout;
private isOnline: boolean = navigator.onLine;
constructor(queryClient: QueryClient, config: Partial<SyncConfig> = {}) {
this.queryClient = queryClient;
this.config = {
enableCrossTabs: true,
enableOptimisticUpdates: true,
enableBackgroundSync: true,
syncInterval: 30000, // 30 seconds
...config,
};
this.initializeSync();
}
private initializeSync() {
// Listen to online/offline events
window.addEventListener('online', this.handleOnline.bind(this));
window.addEventListener('offline', this.handleOffline.bind(this));
// Cross-tab synchronization
if (this.config.enableCrossTabs) {
this.initializeCrossTabSync();
}
// Background sync
if (this.config.enableBackgroundSync) {
this.startBackgroundSync();
}
}
private handleOnline() {
this.isOnline = true;
useUserStore.getState().setOnlineStatus(true);
// Trigger cache revalidation when coming back online
this.queryClient.invalidateQueries();
console.log('DataSync: Back online, revalidating cache');
}
private handleOffline() {
this.isOnline = false;
useUserStore.getState().setOnlineStatus(false);
console.log('DataSync: Offline mode enabled');
}
private initializeCrossTabSync() {
// Listen for storage changes from other tabs
window.addEventListener('storage', (event) => {
if (event.key?.startsWith('motovaultpro-')) {
// Another tab updated store data
if (event.key.includes('user-context')) {
// User data changed in another tab - sync React Query cache
this.syncUserDataFromStorage();
} else if (event.key.includes('mobile-navigation')) {
// Navigation state changed - could affect cache keys
this.syncNavigationFromStorage();
}
}
});
}
private async syncUserDataFromStorage() {
try {
const userData = useUserStore.getState().userProfile;
if (userData) {
// Update query cache with latest user data
this.queryClient.setQueryData(['user', userData.id], userData);
console.log('DataSync: User data synchronized from another tab');
}
} catch (error) {
console.warn('DataSync: Failed to sync user data from storage:', error);
}
}
private async syncNavigationFromStorage() {
try {
const navigationState = useNavigationStore.getState();
// If the selected vehicle changed in another tab, preload its data
if (navigationState.selectedVehicleId) {
await this.queryClient.prefetchQuery({
queryKey: ['vehicles', navigationState.selectedVehicleId],
queryFn: () => this.fetchVehicleById(navigationState.selectedVehicleId!),
});
console.log('DataSync: Vehicle data preloaded from navigation sync');
}
} catch (error) {
console.warn('DataSync: Failed to sync navigation from storage:', error);
}
}
private startBackgroundSync() {
this.syncInterval = setInterval(() => {
if (this.isOnline) {
this.performBackgroundSync();
}
}, this.config.syncInterval);
}
private async performBackgroundSync() {
try {
// Update last sync timestamp
useUserStore.getState().updateLastSync();
// Strategically refresh critical data
const navigationState = useNavigationStore.getState();
// If on vehicles screen, refresh vehicles data
if (navigationState.activeScreen === 'Vehicles') {
await this.queryClient.invalidateQueries({ queryKey: ['vehicles'] });
}
// If viewing specific vehicle, refresh its data
if (navigationState.selectedVehicleId) {
await this.queryClient.invalidateQueries({
queryKey: ['vehicles', navigationState.selectedVehicleId]
});
}
console.log('DataSync: Background sync completed');
} catch (error) {
console.warn('DataSync: Background sync failed:', error);
}
}
// Helper method to fetch vehicle by ID (would normally import from vehicles API)
private async fetchVehicleById(id: string): Promise<Vehicle | null> {
try {
const response = await fetch(`/api/vehicles/${id}`, {
headers: {
'Authorization': this.getAuthHeader(),
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn(`Failed to fetch vehicle ${id}:`, error);
return null;
}
}
private getAuthHeader(): string {
// This would integrate with Auth0 token from interceptor
// For now, return empty string as token is handled by axios interceptor
return '';
}
// Public methods for optimistic updates
public async optimisticVehicleUpdate(vehicleId: string, updates: Partial<Vehicle>) {
if (!this.config.enableOptimisticUpdates) return;
try {
// Optimistically update query cache
this.queryClient.setQueryData(['vehicles', vehicleId], (old: Vehicle | undefined) => {
if (!old) return old;
return { ...old, ...updates };
});
// Also update the vehicles list cache
this.queryClient.setQueryData(['vehicles'], (old: Vehicle[] | undefined) => {
if (!old) return old;
return old.map(vehicle =>
vehicle.id === vehicleId ? { ...vehicle, ...updates } : vehicle
);
});
console.log('DataSync: Optimistic vehicle update applied');
} catch (error) {
console.warn('DataSync: Optimistic update failed:', error);
}
}
public async prefetchForNavigation(targetScreen: string) {
try {
switch (targetScreen) {
case 'Vehicles':
// Prefetch vehicles list if not already cached
await this.queryClient.prefetchQuery({
queryKey: ['vehicles'],
queryFn: () => this.fetchVehicles(),
staleTime: 1000 * 60 * 5, // 5 minutes
});
break;
case 'Log Fuel':
// Prefetch vehicles for fuel logging dropdown
await this.queryClient.prefetchQuery({
queryKey: ['vehicles'],
queryFn: () => this.fetchVehicles(),
});
break;
default:
// No specific prefetching needed
break;
}
} catch (error) {
console.warn('DataSync: Prefetch failed for', targetScreen, error);
}
}
private async fetchVehicles(): Promise<Vehicle[]> {
try {
const response = await fetch('/api/vehicles', {
headers: {
'Authorization': this.getAuthHeader(),
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.warn('Failed to fetch vehicles:', error);
return [];
}
}
public cleanup() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
}
window.removeEventListener('online', this.handleOnline);
window.removeEventListener('offline', this.handleOffline);
}
}

View File

@@ -0,0 +1,117 @@
/**
* @ai-summary React context for unit system preferences
* @ai-context Provides unit preferences and conversion functions throughout the app
*/
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { UnitSystem, UnitPreferences } from './units.types';
import {
formatDistanceBySystem,
formatVolumeBySystem,
formatFuelEfficiencyBySystem,
formatPriceBySystem,
convertDistanceBySystem,
convertVolumeBySystem,
convertFuelEfficiencyBySystem,
getDistanceUnit,
getVolumeUnit,
getFuelEfficiencyUnit
} from './units.utils';
interface UnitsContextType {
unitSystem: UnitSystem;
setUnitSystem: (system: UnitSystem) => void;
preferences: UnitPreferences;
// Conversion functions
convertDistance: (miles: number) => number;
convertVolume: (gallons: number) => number;
convertFuelEfficiency: (mpg: number) => number;
// Formatting functions
formatDistance: (miles: number, precision?: number) => string;
formatVolume: (gallons: number, precision?: number) => string;
formatFuelEfficiency: (mpg: number, precision?: number) => string;
formatPrice: (pricePerGallon: number, currency?: string, precision?: number) => string;
}
const UnitsContext = createContext<UnitsContextType | undefined>(undefined);
interface UnitsProviderProps {
children: ReactNode;
initialSystem?: UnitSystem;
}
export const UnitsProvider: React.FC<UnitsProviderProps> = ({
children,
initialSystem = 'imperial'
}) => {
const [unitSystem, setUnitSystem] = useState<UnitSystem>(initialSystem);
// Load unit preference from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem('motovaultpro-unit-system');
if (stored === 'imperial' || stored === 'metric') {
setUnitSystem(stored);
}
}, []);
// Save unit preference to localStorage when changed
const handleSetUnitSystem = (system: UnitSystem) => {
setUnitSystem(system);
localStorage.setItem('motovaultpro-unit-system', system);
};
// Generate preferences object based on current system
const preferences: UnitPreferences = {
system: unitSystem,
distance: getDistanceUnit(unitSystem),
volume: getVolumeUnit(unitSystem),
fuelEfficiency: getFuelEfficiencyUnit(unitSystem),
};
// Conversion functions using current unit system
const convertDistance = (miles: number) => convertDistanceBySystem(miles, unitSystem);
const convertVolume = (gallons: number) => convertVolumeBySystem(gallons, unitSystem);
const convertFuelEfficiency = (mpg: number) => convertFuelEfficiencyBySystem(mpg, unitSystem);
// Formatting functions using current unit system
const formatDistance = (miles: number, precision?: number) =>
formatDistanceBySystem(miles, unitSystem, precision);
const formatVolume = (gallons: number, precision?: number) =>
formatVolumeBySystem(gallons, unitSystem, precision);
const formatFuelEfficiency = (mpg: number, precision?: number) =>
formatFuelEfficiencyBySystem(mpg, unitSystem, precision);
const formatPrice = (pricePerGallon: number, currency?: string, precision?: number) =>
formatPriceBySystem(pricePerGallon, unitSystem, currency, precision);
const value: UnitsContextType = {
unitSystem,
setUnitSystem: handleSetUnitSystem,
preferences,
convertDistance,
convertVolume,
convertFuelEfficiency,
formatDistance,
formatVolume,
formatFuelEfficiency,
formatPrice,
};
return (
<UnitsContext.Provider value={value}>
{children}
</UnitsContext.Provider>
);
};
export const useUnits = (): UnitsContextType => {
const context = useContext(UnitsContext);
if (context === undefined) {
throw new Error('useUnits must be used within a UnitsProvider');
}
return context;
};

View File

@@ -0,0 +1,24 @@
/**
* @ai-summary Type definitions for unit system support
* @ai-context Frontend types for Imperial/Metric unit preferences
*/
export type UnitSystem = 'imperial' | 'metric';
export type DistanceUnit = 'miles' | 'km';
export type VolumeUnit = 'gallons' | 'liters';
export type FuelEfficiencyUnit = 'mpg' | 'l100km';
export interface UnitPreferences {
system: UnitSystem;
distance: DistanceUnit;
volume: VolumeUnit;
fuelEfficiency: FuelEfficiencyUnit;
}
export interface UserPreferences {
id: string;
userId: string;
unitSystem: UnitSystem;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,194 @@
/**
* @ai-summary Frontend unit conversion utilities
* @ai-context Mirror of backend unit conversion functions for frontend use
*/
import { UnitSystem, DistanceUnit, VolumeUnit, FuelEfficiencyUnit } from './units.types';
// Conversion constants
const MILES_TO_KM = 1.60934;
const KM_TO_MILES = 0.621371;
const GALLONS_TO_LITERS = 3.78541;
const LITERS_TO_GALLONS = 0.264172;
const MPG_TO_L100KM_FACTOR = 235.214;
// Distance Conversions
export function convertDistance(value: number, fromUnit: DistanceUnit, toUnit: DistanceUnit): number {
if (fromUnit === toUnit) return value;
if (fromUnit === 'miles' && toUnit === 'km') {
return value * MILES_TO_KM;
}
if (fromUnit === 'km' && toUnit === 'miles') {
return value * KM_TO_MILES;
}
return value;
}
export function convertDistanceBySystem(miles: number, toSystem: UnitSystem): number {
if (toSystem === 'metric') {
return convertDistance(miles, 'miles', 'km');
}
return miles;
}
// Volume Conversions
export function convertVolume(value: number, fromUnit: VolumeUnit, toUnit: VolumeUnit): number {
if (fromUnit === toUnit) return value;
if (fromUnit === 'gallons' && toUnit === 'liters') {
return value * GALLONS_TO_LITERS;
}
if (fromUnit === 'liters' && toUnit === 'gallons') {
return value * LITERS_TO_GALLONS;
}
return value;
}
export function convertVolumeBySystem(gallons: number, toSystem: UnitSystem): number {
if (toSystem === 'metric') {
return convertVolume(gallons, 'gallons', 'liters');
}
return gallons;
}
// Fuel Efficiency Conversions
export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUnit, toUnit: FuelEfficiencyUnit): number {
if (fromUnit === toUnit) return value;
if (fromUnit === 'mpg' && toUnit === 'l100km') {
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
}
if (fromUnit === 'l100km' && toUnit === 'mpg') {
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
}
return value;
}
export function convertFuelEfficiencyBySystem(mpg: number, toSystem: UnitSystem): number {
if (toSystem === 'metric') {
return convertFuelEfficiency(mpg, 'mpg', 'l100km');
}
return mpg;
}
// Display Formatting Functions
export function formatDistance(value: number, unit: DistanceUnit, precision = 1): string {
if (typeof value !== 'number' || isNaN(value)) {
return unit === 'miles' ? '0 miles' : '0 km';
}
const rounded = parseFloat(value.toFixed(precision));
if (unit === 'miles') {
return `${rounded.toLocaleString()} miles`;
} else {
return `${rounded.toLocaleString()} km`;
}
}
export function formatVolume(value: number, unit: VolumeUnit, precision = 2): string {
if (typeof value !== 'number' || isNaN(value)) {
return unit === 'gallons' ? '0 gal' : '0 L';
}
const rounded = parseFloat(value.toFixed(precision));
if (unit === 'gallons') {
return `${rounded} gal`;
} else {
return `${rounded} L`;
}
}
export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, precision = 1): string {
if (typeof value !== 'number' || isNaN(value)) {
return unit === 'mpg' ? '0 MPG' : '0 L/100km';
}
const rounded = parseFloat(value.toFixed(precision));
if (unit === 'mpg') {
return `${rounded} MPG`;
} else {
return `${rounded} L/100km`;
}
}
export function formatPrice(value: number, unit: VolumeUnit, currency = 'USD', precision = 3): string {
if (typeof value !== 'number' || isNaN(value)) {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
return unit === 'gallons' ? `${formatter.format(0)}/gal` : `${formatter.format(0)}/L`;
}
const rounded = parseFloat(value.toFixed(precision));
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
if (unit === 'gallons') {
return `${formatter.format(rounded)}/gal`;
} else {
return `${formatter.format(rounded)}/L`;
}
}
// System-based formatting (convenience functions)
export function formatDistanceBySystem(miles: number, system: UnitSystem, precision = 1): string {
if (system === 'metric') {
const km = convertDistanceBySystem(miles, system);
return formatDistance(km, 'km', precision);
}
return formatDistance(miles, 'miles', precision);
}
export function formatVolumeBySystem(gallons: number, system: UnitSystem, precision = 2): string {
if (system === 'metric') {
const liters = convertVolumeBySystem(gallons, system);
return formatVolume(liters, 'liters', precision);
}
return formatVolume(gallons, 'gallons', precision);
}
export function formatFuelEfficiencyBySystem(mpg: number, system: UnitSystem, precision = 1): string {
if (system === 'metric') {
const l100km = convertFuelEfficiencyBySystem(mpg, system);
return formatFuelEfficiency(l100km, 'l100km', precision);
}
return formatFuelEfficiency(mpg, 'mpg', precision);
}
export function formatPriceBySystem(pricePerGallon: number, system: UnitSystem, currency = 'USD', precision = 3): string {
if (system === 'metric') {
const pricePerLiter = pricePerGallon * LITERS_TO_GALLONS;
return formatPrice(pricePerLiter, 'liters', currency, precision);
}
return formatPrice(pricePerGallon, 'gallons', currency, precision);
}
// Unit system helpers
export function getDistanceUnit(system: UnitSystem): DistanceUnit {
return system === 'metric' ? 'km' : 'miles';
}
export function getVolumeUnit(system: UnitSystem): VolumeUnit {
return system === 'metric' ? 'liters' : 'gallons';
}
export function getFuelEfficiencyUnit(system: UnitSystem): FuelEfficiencyUnit {
return system === 'metric' ? 'l100km' : 'mpg';
}

View File

@@ -0,0 +1,35 @@
import { apiClient } from '../../../core/api/client';
import { CreateFuelLogRequest, FuelLogResponse, EnhancedFuelStats, FuelType, FuelGradeOption } from '../types/fuel-logs.types';
export const fuelLogsApi = {
async create(data: CreateFuelLogRequest): Promise<FuelLogResponse> {
const res = await apiClient.post('/fuel-logs', data);
return res.data;
},
async getUserFuelLogs(): Promise<FuelLogResponse[]> {
const res = await apiClient.get('/fuel-logs');
return res.data;
},
async getFuelLogsByVehicle(vehicleId: string): Promise<FuelLogResponse[]> {
const res = await apiClient.get(`/fuel-logs/vehicle/${vehicleId}`);
return res.data;
},
async getVehicleStats(vehicleId: string): Promise<EnhancedFuelStats> {
const res = await apiClient.get(`/fuel-logs/vehicle/${vehicleId}/stats`);
return res.data;
},
async getFuelTypes(): Promise<{ value: FuelType; label: string; grades: FuelGradeOption[] }[]> {
const res = await apiClient.get('/fuel-logs/fuel-types');
return res.data.fuelTypes;
},
async getFuelGrades(fuelType: FuelType): Promise<FuelGradeOption[]> {
const res = await apiClient.get(`/fuel-logs/fuel-grades/${fuelType}`);
return res.data.grades;
}
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Card, CardContent, Typography, Box, Chip } from '@mui/material';
import { UnitSystem } from '../types/fuel-logs.types';
interface Props {
fuelUnits?: number;
costPerUnit?: number;
calculatedCost: number;
unitSystem?: UnitSystem;
}
export const CostCalculator: React.FC<Props> = ({ fuelUnits, costPerUnit, calculatedCost, unitSystem = 'imperial' }) => {
const unitLabel = unitSystem === 'imperial' ? 'gallons' : 'liters';
// Ensure we have valid numbers
const safeUnits = typeof fuelUnits === 'number' && !isNaN(fuelUnits) ? fuelUnits : 0;
const safeCostPerUnit = typeof costPerUnit === 'number' && !isNaN(costPerUnit) ? costPerUnit : 0;
const safeCost = typeof calculatedCost === 'number' && !isNaN(calculatedCost) ? calculatedCost : 0;
if (!fuelUnits || !costPerUnit || safeUnits <= 0 || safeCostPerUnit <= 0) {
return (
<Card variant="outlined"><CardContent><Typography variant="body2" color="text.secondary">Enter fuel amount and cost per unit to see total cost.</Typography></CardContent></Card>
);
}
return (
<Card variant="outlined">
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="body2" color="text.secondary">Cost Calculation</Typography>
<Chip label="Real-time" size="small" color="primary" variant="outlined" />
</Box>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="body2">{safeUnits.toFixed(3)} {unitLabel} × ${safeCostPerUnit.toFixed(3)}</Typography>
<Typography variant="h6" color="primary.main" fontWeight={700}>${safeCost.toFixed(2)}</Typography>
</Box>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { TextField, InputAdornment, FormHelperText, Box } from '@mui/material';
import { UnitSystem, DistanceType } from '../types/fuel-logs.types';
interface Props {
type: DistanceType;
value?: number;
onChange: (value: number) => void;
unitSystem?: UnitSystem;
error?: string;
disabled?: boolean;
}
export const DistanceInput: React.FC<Props> = ({ type, value, onChange, unitSystem = 'imperial', error, disabled }) => {
const units = unitSystem === 'imperial' ? 'miles' : 'kilometers';
const label = type === 'odometer' ? `Odometer (${units})` : `Trip Distance (${units})`;
return (
<Box>
<TextField
label={label}
type="number"
value={value ?? ''}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
fullWidth
error={!!error}
disabled={disabled}
inputProps={{ step: type === 'trip' ? 0.1 : 1, min: 0 }}
InputProps={{ endAdornment: <InputAdornment position="end">{units}</InputAdornment> }}
/>
{error && <FormHelperText error>{error}</FormHelperText>}
</Box>
);
};

View File

@@ -0,0 +1,161 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Grid, Card, CardHeader, CardContent, TextField, Switch, FormControlLabel, Box, Button, CircularProgress } from '@mui/material';
import { VehicleSelector } from './VehicleSelector';
import { DistanceInput } from './DistanceInput';
import { FuelTypeSelector } from './FuelTypeSelector';
import { UnitSystemDisplay } from './UnitSystemDisplay';
import { LocationInput } from './LocationInput';
import { CostCalculator } from './CostCalculator';
import { useFuelLogs } from '../hooks/useFuelLogs';
import { useUserSettings } from '../hooks/useUserSettings';
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
const schema = z.object({
vehicleId: z.string().uuid(),
dateTime: z.string().min(1),
odometerReading: z.coerce.number().positive().optional(),
tripDistance: z.coerce.number().positive().optional(),
fuelType: z.nativeEnum(FuelType),
fuelGrade: z.union([z.string(), z.null()]).optional(),
fuelUnits: z.coerce.number().positive(),
costPerUnit: z.coerce.number().positive(),
locationData: z.any().optional(),
notes: z.string().max(500).optional(),
}).refine((d) => (d.odometerReading && d.odometerReading > 0) || (d.tripDistance && d.tripDistance > 0), {
message: 'Either odometer reading or trip distance is required'
}).refine((d) => !(d.odometerReading && d.tripDistance), {
message: 'Cannot specify both odometer reading and trip distance'
});
export const FuelLogForm: React.FC<{ onSuccess?: () => void; initial?: Partial<CreateFuelLogRequest> }> = ({ onSuccess, initial }) => {
const { userSettings } = useUserSettings();
const { createFuelLog, isLoading } = useFuelLogs();
const [useOdometer, setUseOdometer] = useState(false);
const { control, handleSubmit, watch, setValue, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
resolver: zodResolver(schema),
mode: 'onChange',
defaultValues: {
dateTime: new Date().toISOString().slice(0, 16),
fuelType: FuelType.GASOLINE,
...initial
} as any
});
const watched = watch(['fuelUnits', 'costPerUnit']);
const [fuelUnitsRaw, costPerUnitRaw] = watched as [string | number | undefined, string | number | undefined];
// Convert to numbers for calculation
const fuelUnits = typeof fuelUnitsRaw === 'string' ? parseFloat(fuelUnitsRaw) : fuelUnitsRaw;
const costPerUnit = typeof costPerUnitRaw === 'string' ? parseFloat(costPerUnitRaw) : costPerUnitRaw;
const calculatedCost = useMemo(() => {
const units = fuelUnits && !isNaN(fuelUnits) ? fuelUnits : 0;
const cost = costPerUnit && !isNaN(costPerUnit) ? costPerUnit : 0;
return units > 0 && cost > 0 ? units * cost : 0;
}, [fuelUnits, costPerUnit]);
const onSubmit = async (data: CreateFuelLogRequest) => {
const payload: CreateFuelLogRequest = {
...data,
odometerReading: useOdometer ? data.odometerReading : undefined,
tripDistance: useOdometer ? undefined : data.tripDistance,
};
await createFuelLog(payload);
onSuccess?.();
};
useEffect(() => {
if (useOdometer) setValue('tripDistance', undefined as any);
else setValue('odometerReading', undefined as any);
}, [useOdometer, setValue]);
return (
<Card>
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
<CardContent>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Controller name="vehicleId" control={control} render={({ field }) => (
<VehicleSelector value={field.value} onChange={field.onChange} error={errors.vehicleId?.message} required />
)} />
</Grid>
<Grid item xs={12} sm={6}>
<Controller name="dateTime" control={control} render={({ field }) => (
<TextField {...field} label="Date & Time" type="datetime-local" fullWidth error={!!errors.dateTime} helperText={errors.dateTime?.message} InputLabelProps={{ shrink: true }} />
)} />
</Grid>
<Grid item xs={12} sm={6}>
<FormControlLabel control={<Switch checked={useOdometer} onChange={(e) => setUseOdometer(e.target.checked)} />} label={`Use ${useOdometer ? 'Odometer' : 'Trip Distance'}`} />
</Grid>
<Grid item xs={12} sm={6}>
<Controller name={useOdometer ? 'odometerReading' : 'tripDistance'} control={control} render={({ field }) => (
<DistanceInput type={useOdometer ? 'odometer' : 'trip'} value={field.value as any} onChange={field.onChange as any} unitSystem={userSettings?.unitSystem} error={useOdometer ? (errors.odometerReading?.message as any) : (errors.tripDistance?.message as any)} />
)} />
</Grid>
<Grid item xs={12}>
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => (
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => (
<FuelTypeSelector fuelType={fuelTypeField.value} fuelGrade={fuelGradeField.value as any} onFuelTypeChange={fuelTypeField.onChange} onFuelGradeChange={fuelGradeField.onChange as any} error={(errors.fuelType?.message as any) || (errors.fuelGrade?.message as any)} />
)} />
)} />
</Grid>
<Grid item xs={12} sm={6}>
<Controller name="fuelUnits" control={control} render={({ field }) => (
<TextField
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
label={`Fuel Amount (${userSettings?.unitSystem === 'imperial' ? 'gallons' : 'liters'})`}
type="number"
inputProps={{ step: 0.001, min: 0.001 }}
fullWidth
error={!!errors.fuelUnits}
helperText={errors.fuelUnits?.message}
/>
)} />
</Grid>
<Grid item xs={12} sm={6}>
<Controller name="costPerUnit" control={control} render={({ field }) => (
<TextField
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
label={`Cost Per ${userSettings?.unitSystem === 'imperial' ? 'Gallon' : 'Liter'}`}
type="number"
inputProps={{ step: 0.001, min: 0.001 }}
fullWidth
error={!!errors.costPerUnit}
helperText={errors.costPerUnit?.message}
/>
)} />
</Grid>
<Grid item xs={12}>
<CostCalculator fuelUnits={fuelUnits} costPerUnit={costPerUnit} calculatedCost={calculatedCost} unitSystem={userSettings?.unitSystem} />
</Grid>
<Grid item xs={12}>
<Controller name="locationData" control={control} render={({ field }) => (
<LocationInput value={field.value as any} onChange={field.onChange as any} placeholder="Station location (optional)" />
)} />
</Grid>
<Grid item xs={12}>
<Controller name="notes" control={control} render={({ field }) => (
<TextField {...field} label="Notes (optional)" multiline rows={3} fullWidth error={!!errors.notes} helperText={errors.notes?.message} />
)} />
</Grid>
<Grid item xs={12}>
<Box display="flex" gap={2} justifyContent="flex-end">
<Button type="submit" variant="contained" disabled={!isValid || isLoading} startIcon={isLoading ? <CircularProgress size={18} /> : undefined}>Add Fuel Log</Button>
</Box>
</Grid>
</Grid>
</form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Card, CardContent, Typography, List, ListItem, ListItemText, Chip, Box } from '@mui/material';
import { FuelLogResponse } from '../types/fuel-logs.types';
export const FuelLogsList: React.FC<{ logs?: FuelLogResponse[] }>= ({ logs }) => {
if (!logs || logs.length === 0) {
return (
<Card variant="outlined"><CardContent><Typography variant="body2" color="text.secondary">No fuel logs yet.</Typography></CardContent></Card>
);
}
return (
<List>
{logs.map((log) => (
<ListItem key={log.id} divider>
<ListItemText
primary={`${new Date(log.dateTime).toLocaleString()} $${(log.totalCost || 0).toFixed(2)}`}
secondary={`${(log.fuelUnits || 0).toFixed(3)} @ $${(log.costPerUnit || 0).toFixed(3)}${log.odometerReading ? `Odo: ${log.odometerReading}` : `Trip: ${log.tripDistance}`}`}
/>
{log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && (
<Box><Chip label={`${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`} size="small" color="primary" /></Box>
)}
</ListItem>
))}
</List>
);
};

View File

@@ -0,0 +1,38 @@
import React, { useMemo } from 'react';
import { Card, CardContent, Grid, Typography } from '@mui/material';
import { FuelLogResponse } from '../types/fuel-logs.types';
import { useUnits } from '../../../core/units/UnitsContext';
export const FuelStatsCard: React.FC<{ logs?: FuelLogResponse[] }> = ({ logs }) => {
const { unitSystem } = useUnits();
const stats = useMemo(() => {
if (!logs || logs.length === 0) return { count: 0, totalUnits: 0, totalCost: 0 };
const totalUnits = logs.reduce((s, l) => s + (l.fuelUnits || 0), 0);
const totalCost = logs.reduce((s, l) => s + (l.totalCost || 0), 0);
return { count: logs.length, totalUnits, totalCost };
}, [logs]);
const unitLabel = unitSystem === 'imperial' ? 'gallons' : 'liters';
return (
<Card variant="outlined">
<CardContent>
<Grid container spacing={2}>
<Grid item xs={4}>
<Typography variant="overline" color="text.secondary">Logs</Typography>
<Typography variant="h6">{stats.count}</Typography>
</Grid>
<Grid item xs={4}>
<Typography variant="overline" color="text.secondary">Total Fuel</Typography>
<Typography variant="h6">{(stats.totalUnits || 0).toFixed(2)} {unitLabel}</Typography>
</Grid>
<Grid item xs={4}>
<Typography variant="overline" color="text.secondary">Total Cost</Typography>
<Typography variant="h6">${(stats.totalCost || 0).toFixed(2)}</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { FormControl, InputLabel, Select, MenuItem, Grid, FormHelperText } from '@mui/material';
import { FuelType, FuelGrade } from '../types/fuel-logs.types';
import { useFuelGrades } from '../hooks/useFuelGrades';
interface Props {
fuelType: FuelType;
fuelGrade?: FuelGrade;
onFuelTypeChange: (fuelType: FuelType) => void;
onFuelGradeChange: (fuelGrade?: FuelGrade) => void;
error?: string;
disabled?: boolean;
}
export const FuelTypeSelector: React.FC<Props> = ({ fuelType, fuelGrade, onFuelTypeChange, onFuelGradeChange, error, disabled }) => {
const { fuelGrades, isLoading } = useFuelGrades(fuelType);
useEffect(() => {
if (fuelGrade && fuelGrades && !fuelGrades.some(g => g.value === fuelGrade)) {
onFuelGradeChange(undefined);
}
}, [fuelType, fuelGrades, fuelGrade, onFuelGradeChange]);
useEffect(() => {
if (!fuelGrade && fuelGrades && fuelGrades.length > 0) {
onFuelGradeChange(fuelGrades[0].value as FuelGrade);
}
}, [fuelGrades, fuelGrade, onFuelGradeChange]);
return (
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth error={!!error}>
<InputLabel>Fuel Type</InputLabel>
<Select value={fuelType} label="Fuel Type" onChange={(e) => onFuelTypeChange(e.target.value as FuelType)} disabled={disabled}>
<MenuItem value={FuelType.GASOLINE}>Gasoline</MenuItem>
<MenuItem value={FuelType.DIESEL}>Diesel</MenuItem>
<MenuItem value={FuelType.ELECTRIC}>Electric</MenuItem>
</Select>
{error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth disabled={disabled || isLoading || fuelType === FuelType.ELECTRIC}>
<InputLabel>Fuel Grade</InputLabel>
<Select value={fuelGrade || ''} label="Fuel Grade" onChange={(e) => onFuelGradeChange(e.target.value as FuelGrade)}>
{fuelGrades?.map((g) => (
<MenuItem key={g.value || 'none'} value={g.value || ''}>{g.label}</MenuItem>
))}
</Select>
{fuelType !== FuelType.ELECTRIC && <FormHelperText>{isLoading ? 'Loading grades…' : 'Select a grade'}</FormHelperText>}
</FormControl>
</Grid>
</Grid>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { TextField } from '@mui/material';
import { LocationData } from '../types/fuel-logs.types';
interface Props {
value?: LocationData;
onChange: (value?: LocationData) => void;
placeholder?: string;
}
export const LocationInput: React.FC<Props> = ({ value, onChange, placeholder }) => {
return (
<TextField
label="Location (optional)"
placeholder={placeholder}
fullWidth
value={value?.stationName || value?.address || ''}
onChange={(e) => onChange({ ...(value || {}), stationName: e.target.value })}
/>
);
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Typography } from '@mui/material';
import { UnitSystem } from '../types/fuel-logs.types';
export const UnitSystemDisplay: React.FC<{ unitSystem?: UnitSystem; showLabel?: string }> = ({ unitSystem, showLabel }) => {
if (!unitSystem) return null;
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, L/100km)';
return (
<Typography variant="caption" color="text.secondary">
{showLabel ? `${showLabel} ` : ''}{label}
</Typography>
);
};

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { FormControl, InputLabel, Select, MenuItem, FormHelperText, Box, Typography } from '@mui/material';
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import type { Vehicle } from '../../vehicles/types/vehicles.types';
interface Props {
value?: string;
onChange: (vehicleId: string) => void;
error?: string;
required?: boolean;
disabled?: boolean;
}
export const VehicleSelector: React.FC<Props> = ({ value, onChange, error, required, disabled }) => {
const { data: vehicles, isLoading } = useVehicles();
if (!isLoading && (vehicles?.length || 0) === 0) {
return (
<Box p={2} borderRadius={1} bgcolor={'background.default'}>
<Typography variant="body2" color="text.secondary">
You need to add a vehicle before creating fuel logs.
</Typography>
</Box>
);
}
return (
<FormControl fullWidth error={!!error} required={required}>
<InputLabel>Select Vehicle</InputLabel>
<Select value={value || ''} onChange={(e) => onChange(e.target.value as string)} label="Select Vehicle" disabled={disabled}>
{vehicles?.map((v: Vehicle) => (
<MenuItem key={v.id} value={v.id}>
<Box display="flex" alignItems="center" gap={1}>
<DirectionsCarIcon fontSize="small" />
<Typography variant="body2">{`${v.year || ''} ${v.make || ''} ${v.model || ''}`.trim()}</Typography>
</Box>
</MenuItem>
))}
</Select>
{error && <FormHelperText>{error}</FormHelperText>}
</FormControl>
);
};

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { fuelLogsApi } from '../api/fuel-logs.api';
import { FuelType, FuelGradeOption } from '../types/fuel-logs.types';
export const useFuelGrades = (fuelType: FuelType) => {
const { data, isLoading, error } = useQuery<FuelGradeOption[]>({
queryKey: ['fuelGrades', fuelType],
queryFn: () => fuelLogsApi.getFuelGrades(fuelType),
});
return { fuelGrades: data || [], isLoading, error };
};

View File

@@ -0,0 +1,36 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fuelLogsApi } from '../api/fuel-logs.api';
import { CreateFuelLogRequest, FuelLogResponse, EnhancedFuelStats } from '../types/fuel-logs.types';
export const useFuelLogs = (vehicleId?: string) => {
const queryClient = useQueryClient();
const logsQuery = useQuery<FuelLogResponse[]>({
queryKey: ['fuelLogs', vehicleId || 'all'],
queryFn: () => (vehicleId ? fuelLogsApi.getFuelLogsByVehicle(vehicleId) : fuelLogsApi.getUserFuelLogs()),
});
const statsQuery = useQuery<EnhancedFuelStats>({
queryKey: ['fuelLogsStats', vehicleId],
queryFn: () => fuelLogsApi.getVehicleStats(vehicleId!),
enabled: !!vehicleId,
});
const createMutation = useMutation({
mutationFn: (data: CreateFuelLogRequest) => fuelLogsApi.create(data),
onSuccess: (_res, variables) => {
queryClient.invalidateQueries({ queryKey: ['fuelLogs', variables.vehicleId] });
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats', variables.vehicleId] });
},
});
return {
fuelLogs: logsQuery.data,
isLoading: logsQuery.isLoading || createMutation.isPending,
error: logsQuery.error,
stats: statsQuery.data,
isStatsLoading: statsQuery.isLoading,
createFuelLog: createMutation.mutateAsync,
};
};

View File

@@ -0,0 +1,15 @@
import { useUnits } from '../../../core/units/UnitsContext';
import { UnitSystem } from '../types/fuel-logs.types';
export const useUserSettings = () => {
const { unitSystem } = useUnits();
// Placeholder for future: fetch currency/timezone from a settings API
return {
userSettings: {
unitSystem: unitSystem as UnitSystem,
currencyCode: 'USD',
timeZone: 'UTC',
},
};
};

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Grid, Typography } from '@mui/material';
import { FuelLogForm } from '../components/FuelLogForm';
import { FuelLogsList } from '../components/FuelLogsList';
import { useFuelLogs } from '../hooks/useFuelLogs';
import { FuelStatsCard } from '../components/FuelStatsCard';
export const FuelLogsPage: React.FC = () => {
const { fuelLogs } = useFuelLogs();
return (
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FuelLogForm />
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="h6" gutterBottom>Recent Fuel Logs</Typography>
<FuelLogsList logs={fuelLogs} />
<Typography variant="h6" sx={{ mt: 3 }} gutterBottom>Summary</Typography>
<FuelStatsCard logs={fuelLogs} />
</Grid>
</Grid>
);
};

View File

@@ -0,0 +1,72 @@
/**
* @ai-summary Types for enhanced fuel logs UI
*/
export type UnitSystem = 'imperial' | 'metric';
export enum FuelType {
GASOLINE = 'gasoline',
DIESEL = 'diesel',
ELECTRIC = 'electric'
}
export type FuelGrade = '87' | '88' | '89' | '91' | '93' | '#1' | '#2' | null;
export interface LocationData {
address?: string;
coordinates?: { latitude: number; longitude: number };
googlePlaceId?: string;
stationName?: string;
}
export type DistanceType = 'odometer' | 'trip';
export interface CreateFuelLogRequest {
vehicleId: string;
dateTime: string;
odometerReading?: number;
tripDistance?: number;
fuelType: FuelType;
fuelGrade?: FuelGrade;
fuelUnits: number;
costPerUnit: number;
locationData?: LocationData;
notes?: string;
}
export interface FuelLogResponse {
id: string;
userId: string;
vehicleId: string;
dateTime: string;
odometerReading?: number;
tripDistance?: number;
fuelType: FuelType;
fuelGrade?: FuelGrade;
fuelUnits: number;
costPerUnit: number;
totalCost: number;
locationData?: LocationData;
efficiency?: number;
efficiencyLabel: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface EnhancedFuelStats {
logCount: number;
totalFuelUnits: number;
totalCost: number;
averageCostPerUnit: number;
totalDistance: number;
averageEfficiency: number;
unitLabels: { fuelUnits: string; distanceUnits: string; efficiencyUnits: string };
}
export interface FuelGradeOption {
value: FuelGrade;
label: string;
description?: string;
}

View File

@@ -0,0 +1,58 @@
import { useState, useEffect } from 'react';
import { useSettingsPersistence, SettingsState } from './useSettingsPersistence';
const defaultSettings: SettingsState = {
darkMode: false,
unitSystem: 'imperial',
notifications: {
email: true,
push: true,
maintenance: true,
},
};
export const useSettings = () => {
const { loadSettings, saveSettings } = useSettingsPersistence();
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
try {
setIsLoading(true);
setError(null);
const savedSettings = loadSettings();
if (savedSettings) {
setSettings(savedSettings);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load settings');
} finally {
setIsLoading(false);
}
}, [loadSettings]);
const updateSetting = <K extends keyof SettingsState>(
key: K,
value: SettingsState[K]
) => {
try {
setError(null);
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
saveSettings(newSettings);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save settings');
}
};
return {
settings,
updateSetting,
isLoading,
error,
};
};
export type { SettingsState } from './useSettingsPersistence';

View File

@@ -0,0 +1,38 @@
import { useCallback } from 'react';
export interface SettingsState {
darkMode: boolean;
unitSystem: 'imperial' | 'metric';
notifications: {
email: boolean;
push: boolean;
maintenance: boolean;
};
}
const SETTINGS_STORAGE_KEY = 'motovaultpro-mobile-settings';
export const useSettingsPersistence = () => {
const loadSettings = useCallback((): SettingsState | null => {
try {
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error('Error loading settings:', error);
return null;
}
}, []);
const saveSettings = useCallback((settings: SettingsState) => {
try {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
console.error('Error saving settings:', error);
}
}, []);
return {
loadSettings,
saveSettings,
};
};

View File

@@ -0,0 +1,323 @@
import React, { useState } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { useSettings } from '../hooks/useSettings';
interface ToggleSwitchProps {
enabled: boolean;
onChange: () => void;
label: string;
description?: string;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
enabled,
onChange,
label,
description
}) => (
<div className="flex items-center justify-between py-2">
<div>
<p className="font-medium text-slate-800">{label}</p>
{description && (
<p className="text-sm text-slate-500">{description}</p>
)}
</div>
<button
onClick={onChange}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
{children}
<div className="flex justify-end mt-4">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium"
>
Close
</button>
</div>
</div>
</div>
);
};
export const MobileSettingsScreen: React.FC = () => {
const { user, logout } = useAuth0();
const { settings, updateSetting, isLoading, error } = useSettings();
const [showDataExport, setShowDataExport] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleLogout = () => {
logout({
logoutParams: {
returnTo: window.location.origin
}
});
};
const handleExportData = () => {
// TODO: Implement data export functionality
console.log('Exporting user data...');
setShowDataExport(false);
};
const handleDeleteAccount = () => {
// TODO: Implement account deletion
console.log('Deleting account...');
setShowDeleteConfirm(false);
};
// Loading state
if (isLoading) {
return (
<MobileContainer>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="text-slate-500 mb-2">Loading settings...</div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
</MobileContainer>
);
}
// Error state
if (error) {
return (
<MobileContainer>
<div className="space-y-4 pb-20 p-4">
<GlassCard padding="md">
<div className="text-center py-8">
<p className="text-red-600 mb-4">Failed to load settings</p>
<p className="text-sm text-slate-600 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Retry
</button>
</div>
</GlassCard>
</div>
</MobileContainer>
);
}
return (
<MobileContainer>
<div className="space-y-4 pb-20 p-4">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Settings</h1>
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
</div>
{/* Account Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account</h2>
<div className="flex items-center space-x-3">
{user?.picture && (
<img
src={user.picture}
alt="Profile"
className="w-12 h-12 rounded-full"
/>
)}
<div>
<p className="font-medium text-slate-800">{user?.name}</p>
<p className="text-sm text-slate-500">{user?.email}</p>
</div>
</div>
<div className="pt-3 mt-3 border-t border-slate-200">
<p className="text-sm text-slate-600">
Member since {user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'Unknown'}
</p>
</div>
</div>
</GlassCard>
{/* Notifications Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Notifications</h2>
<div className="space-y-3">
<ToggleSwitch
enabled={settings.notifications.email}
onChange={() => updateSetting('notifications', {
...settings.notifications,
email: !settings.notifications.email
})}
label="Email Notifications"
description="Receive updates via email"
/>
<ToggleSwitch
enabled={settings.notifications.push}
onChange={() => updateSetting('notifications', {
...settings.notifications,
push: !settings.notifications.push
})}
label="Push Notifications"
description="Receive mobile push notifications"
/>
<ToggleSwitch
enabled={settings.notifications.maintenance}
onChange={() => updateSetting('notifications', {
...settings.notifications,
maintenance: !settings.notifications.maintenance
})}
label="Maintenance Reminders"
description="Get reminded about vehicle maintenance"
/>
</div>
</div>
</GlassCard>
{/* Appearance & Units Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Appearance & Units</h2>
<div className="space-y-4">
<ToggleSwitch
enabled={settings.darkMode}
onChange={() => updateSetting('darkMode', !settings.darkMode)}
label="Dark Mode"
description="Switch to dark theme"
/>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-800">Unit System</p>
<p className="text-sm text-slate-500">
Currently using {settings.unitSystem === 'imperial' ? 'Miles & Gallons' : 'Kilometers & Liters'}
</p>
</div>
<button
onClick={() => updateSetting('unitSystem', settings.unitSystem === 'imperial' ? 'metric' : 'imperial')}
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
>
{settings.unitSystem === 'imperial' ? 'Switch to Metric' : 'Switch to Imperial'}
</button>
</div>
</div>
</div>
</GlassCard>
{/* Data Management Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Data Management</h2>
<div className="space-y-3">
<button
onClick={() => setShowDataExport(true)}
className="w-full text-left p-3 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors"
>
Export My Data
</button>
<p className="text-sm text-slate-500">
Download a copy of all your vehicle and fuel data
</p>
</div>
</div>
</GlassCard>
{/* Account Actions Section */}
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account Actions</h2>
<div className="space-y-3">
<button
onClick={handleLogout}
className="w-full py-3 px-4 bg-gray-100 text-gray-700 rounded-lg text-left font-medium hover:bg-gray-200 transition-colors"
>
Sign Out
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full py-3 px-4 bg-red-50 text-red-600 rounded-lg text-left font-medium hover:bg-red-100 transition-colors"
>
Delete Account
</button>
</div>
</div>
</GlassCard>
{/* Data Export Modal */}
<Modal
isOpen={showDataExport}
onClose={() => setShowDataExport(false)}
title="Export Data"
>
<p className="text-slate-600 mb-4">
This will create a downloadable file containing all your vehicle data, fuel logs, and preferences.
</p>
<div className="flex space-x-3">
<button
onClick={() => setShowDataExport(false)}
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={handleExportData}
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Export
</button>
</div>
</Modal>
{/* Delete Account Confirmation */}
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title="Delete Account"
>
<p className="text-slate-600 mb-4">
This action cannot be undone. All your data will be permanently deleted.
</p>
<div className="flex space-x-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={handleDeleteAccount}
className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Delete
</button>
</div>
</Modal>
</div>
</MobileContainer>
);
};

View File

@@ -3,18 +3,9 @@
*/
import { apiClient } from '../../../core/api/client';
import axios from 'axios';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption } from '../types/vehicles.types';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption, VINDecodeResponse } from '../types/vehicles.types';
// Unauthenticated client for dropdown data
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
const dropdownClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// All requests (including dropdowns) use authenticated apiClient
export const vehiclesApi = {
getAll: async (): Promise<Vehicle[]> => {
@@ -41,29 +32,40 @@ export const vehiclesApi = {
await apiClient.delete(`/vehicles/${id}`);
},
// Dropdown API methods (unauthenticated)
getMakes: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/makes');
// Dropdown API methods (authenticated)
getYears: async (): Promise<number[]> => {
const response = await apiClient.get('/vehicles/dropdown/years');
return response.data;
},
getModels: async (make: string): Promise<DropdownOption[]> => {
const response = await dropdownClient.get(`/vehicles/dropdown/models/${encodeURIComponent(make)}`);
getMakes: async (year: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/makes?year=${year}`);
return response.data;
},
getTransmissions: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/transmissions');
getModels: async (year: number, makeId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/models?year=${year}&make_id=${makeId}`);
return response.data;
},
getEngines: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/engines');
getTransmissions: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/transmissions?year=${year}&make_id=${makeId}&model_id=${modelId}`);
return response.data;
},
getTrims: async (): Promise<DropdownOption[]> => {
const response = await dropdownClient.get('/vehicles/dropdown/trims');
getEngines: async (year: number, makeId: number, modelId: number, trimId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/engines?year=${year}&make_id=${makeId}&model_id=${modelId}&trim_id=${trimId}`);
return response.data;
},
};
getTrims: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make_id=${makeId}&model_id=${modelId}`);
return response.data;
},
// VIN decode method
decodeVIN: async (vin: string): Promise<VINDecodeResponse> => {
const response = await apiClient.post('/vehicles/decode-vin', { vin });
return response.data;
},
};

View File

@@ -7,6 +7,7 @@ import { Card, CardContent, CardActionArea, Box, Typography, IconButton } from '
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { Vehicle } from '../types/vehicles.types';
import { useUnits } from '../../../core/units/UnitsContext';
interface VehicleCardProps {
vehicle: Vehicle;
@@ -35,8 +36,9 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
onDelete,
onSelect,
}) => {
const { formatDistance } = useUnits();
const displayName = vehicle.nickname ||
`${vehicle.year} ${vehicle.make} ${vehicle.model}`;
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ');
return (
<Card
@@ -72,7 +74,7 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
)}
<Typography variant="body2" color="text.primary" sx={{ mt: 1, fontWeight: 500 }}>
Odometer: {vehicle.odometerReading.toLocaleString()} miles
Odometer: {formatDistance(vehicle.odometerReading)}
</Typography>
</CardContent>
</CardActionArea>

View File

@@ -10,20 +10,49 @@ import { Button } from '../../../shared-minimal/components/Button';
import { CreateVehicleRequest, DropdownOption } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
const vehicleSchema = z.object({
vin: z.string().length(17, 'VIN must be exactly 17 characters'),
make: z.string().optional(),
model: z.string().optional(),
engine: z.string().optional(),
transmission: z.string().optional(),
trimLevel: z.string().optional(),
driveType: z.string().optional(),
fuelType: z.string().optional(),
nickname: z.string().optional(),
color: z.string().optional(),
licensePlate: z.string().optional(),
odometerReading: z.number().min(0).optional(),
});
const vehicleSchema = z
.object({
vin: z.string().optional(),
year: z.number().min(1980).max(new Date().getFullYear() + 1).optional(),
make: z.string().optional(),
model: z.string().optional(),
engine: z.string().optional(),
transmission: z.string().optional(),
trimLevel: z.string().optional(),
driveType: z.string().optional(),
fuelType: z.string().optional(),
nickname: z.string().optional(),
color: z.string().optional(),
licensePlate: z.string().optional(),
odometerReading: z.number().min(0).optional(),
})
.refine(
(data) => {
const vin = (data.vin || '').trim();
const plate = (data.licensePlate || '').trim();
// Must have either a valid 17-char VIN or a non-empty license plate
if (vin.length === 17) return true;
if (plate.length > 0) return true;
return false;
},
{
message: 'Either a valid 17-character VIN or a license plate is required',
path: ['vin'],
}
)
.refine(
(data) => {
const vin = (data.vin || '').trim();
const plate = (data.licensePlate || '').trim();
// If VIN provided but not 17 and no plate, fail; if plate exists, allow any VIN (or empty)
if (plate.length > 0) return true;
return vin.length === 17 || vin.length === 0;
},
{
message: 'VIN must be exactly 17 characters when license plate is not provided',
path: ['vin'],
}
);
interface VehicleFormProps {
onSubmit: (data: CreateVehicleRequest) => void;
@@ -38,13 +67,18 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
initialData,
loading,
}) => {
const [years, setYears] = useState<number[]>([]);
const [makes, setMakes] = useState<DropdownOption[]>([]);
const [models, setModels] = useState<DropdownOption[]>([]);
const [transmissions, setTransmissions] = useState<DropdownOption[]>([]);
const [engines, setEngines] = useState<DropdownOption[]>([]);
const [trims, setTrims] = useState<DropdownOption[]>([]);
const [selectedMake, setSelectedMake] = useState<string>('');
const [selectedYear, setSelectedYear] = useState<number | undefined>();
const [selectedMake, setSelectedMake] = useState<DropdownOption | undefined>();
const [selectedModel, setSelectedModel] = useState<DropdownOption | undefined>();
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const [selectedTrim, setSelectedTrim] = useState<DropdownOption | undefined>();
const [decodingVIN, setDecodingVIN] = useState(false);
const [decodeSuccess, setDecodeSuccess] = useState(false);
const {
register,
@@ -57,73 +91,226 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
defaultValues: initialData,
});
const watchedYear = watch('year');
const watchedMake = watch('make');
const watchedModel = watch('model');
const watchedVIN = watch('vin');
// Load dropdown data on component mount
useEffect(() => {
const loadInitialData = async () => {
setLoadingDropdowns(true);
try {
const [makesData, transmissionsData, enginesData, trimsData] = await Promise.all([
vehiclesApi.getMakes(),
vehiclesApi.getTransmissions(),
vehiclesApi.getEngines(),
vehiclesApi.getTrims(),
]);
// VIN decode handler
const handleDecodeVIN = async () => {
const vin = watchedVIN;
if (!vin || vin.length !== 17) {
return;
}
setDecodingVIN(true);
setDecodeSuccess(false);
try {
const result = await vehiclesApi.decodeVIN(vin);
if (result.success) {
// Auto-populate fields with decoded values
if (result.year) setValue('year', result.year);
if (result.make) setValue('make', result.make);
if (result.model) setValue('model', result.model);
if (result.transmission) setValue('transmission', result.transmission);
if (result.engine) setValue('engine', result.engine);
if (result.trimLevel) setValue('trimLevel', result.trimLevel);
setMakes(makesData);
setTransmissions(transmissionsData);
setEngines(enginesData);
setTrims(trimsData);
setDecodeSuccess(true);
setTimeout(() => setDecodeSuccess(false), 3000); // Hide success after 3 seconds
}
} catch (error) {
console.error('VIN decode failed:', error);
} finally {
setDecodingVIN(false);
}
};
// Load years on component mount
useEffect(() => {
const loadYears = async () => {
try {
const yearsData = await vehiclesApi.getYears();
setYears(yearsData);
} catch (error) {
console.error('Failed to load dropdown data:', error);
} finally {
setLoadingDropdowns(false);
console.error('Failed to load years:', error);
}
};
loadInitialData();
loadYears();
}, []);
// Load models when make changes
// Load makes when year changes
useEffect(() => {
if (watchedMake && watchedMake !== selectedMake) {
const loadModels = async () => {
if (watchedYear && watchedYear !== selectedYear) {
const loadMakes = async () => {
setLoadingDropdowns(true);
try {
const modelsData = await vehiclesApi.getModels(watchedMake);
setModels(modelsData);
setSelectedMake(watchedMake);
const makesData = await vehiclesApi.getMakes(watchedYear);
setMakes(makesData);
setSelectedYear(watchedYear);
// Clear model selection when make changes
setValue('model', '');
} catch (error) {
console.error('Failed to load models:', error);
// Clear dependent selections
setModels([]);
setEngines([]);
setTrims([]);
setSelectedMake(undefined);
setSelectedModel(undefined);
setValue('make', '');
setValue('model', '');
setValue('transmission', '');
setValue('engine', '');
setValue('trimLevel', '');
} catch (error) {
console.error('Failed to load makes:', error);
setMakes([]);
} finally {
setLoadingDropdowns(false);
}
};
loadModels();
loadMakes();
}
}, [watchedMake, selectedMake, setValue]);
}, [watchedYear, selectedYear, setValue]);
// Load models when make changes
useEffect(() => {
if (watchedMake && watchedYear && watchedMake !== selectedMake?.name) {
const makeOption = makes.find(make => make.name === watchedMake);
if (makeOption) {
const loadModels = async () => {
setLoadingDropdowns(true);
try {
const modelsData = await vehiclesApi.getModels(watchedYear, makeOption.id);
setModels(modelsData);
setSelectedMake(makeOption);
// Clear dependent selections
setEngines([]);
setTrims([]);
setSelectedModel(undefined);
setValue('model', '');
setValue('transmission', '');
setValue('engine', '');
setValue('trimLevel', '');
} catch (error) {
console.error('Failed to load models:', error);
setModels([]);
} finally {
setLoadingDropdowns(false);
}
};
loadModels();
}
}
}, [watchedMake, watchedYear, selectedMake, makes, setValue]);
// Load trims when model changes
useEffect(() => {
if (watchedModel && watchedYear && selectedMake && watchedModel !== selectedModel?.name) {
const modelOption = models.find(model => model.name === watchedModel);
if (modelOption) {
const loadTrims = async () => {
setLoadingDropdowns(true);
try {
const trimsData = await vehiclesApi.getTrims(watchedYear, selectedMake.id, modelOption.id);
setTrims(trimsData);
setSelectedModel(modelOption);
// Clear deeper selections
setEngines([]);
setSelectedTrim(undefined);
setValue('transmission', '');
setValue('engine', '');
setValue('trimLevel', '');
} catch (error) {
console.error('Failed to load detailed data:', error);
setTrims([]);
setEngines([]);
} finally {
setLoadingDropdowns(false);
}
};
loadTrims();
}
}
}, [watchedModel, watchedYear, selectedMake, selectedModel, models, setValue]);
// Load engines when trim changes
useEffect(() => {
const trimName = watch('trimLevel');
if (trimName && watchedYear && selectedMake && selectedModel) {
const trimOption = trims.find(t => t.name === trimName);
if (trimOption) {
const loadEngines = async () => {
setLoadingDropdowns(true);
try {
const enginesData = await vehiclesApi.getEngines(watchedYear, selectedMake.id, selectedModel.id, trimOption.id);
setEngines(enginesData);
setSelectedTrim(trimOption);
} catch (error) {
console.error('Failed to load engines:', error);
setEngines([]);
} finally {
setLoadingDropdowns(false);
}
};
loadEngines();
}
}
}, [trims, selectedMake, selectedModel, watchedYear, setValue, watch('trimLevel')]);
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
VIN <span className="text-red-500">*</span>
VIN or License Plate <span className="text-red-500">*</span>
</label>
<input
{...register('vin')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Enter 17-character VIN"
/>
<div className="flex gap-2">
<input
{...register('vin')}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Enter 17-character VIN (optional if License Plate provided)"
/>
<Button
type="button"
onClick={handleDecodeVIN}
loading={decodingVIN}
disabled={!watchedVIN || watchedVIN.length !== 17}
variant="secondary"
>
Decode
</Button>
</div>
{decodeSuccess && (
<p className="mt-1 text-sm text-green-600">VIN decoded successfully! Fields populated.</p>
)}
{errors.vin && (
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
)}
</div>
</div>
{/* Vehicle Specification Dropdowns */}
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Year
</label>
<select
{...register('year', { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Select Year</option>
{years.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Make
@@ -131,7 +318,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<select
{...register('make')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={loadingDropdowns}
disabled={loadingDropdowns || !watchedYear}
>
<option value="">Select Make</option>
{makes.map((make) => (
@@ -149,7 +336,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<select
{...register('model')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={!watchedMake || models.length === 0}
disabled={loadingDropdowns || !watchedMake || models.length === 0}
>
<option value="">Select Model</option>
{models.map((model) => (
@@ -162,6 +349,26 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</div>
<div className="grid grid-cols-3 gap-4">
{/* Trim (left) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Trim
</label>
<select
{...register('trimLevel')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={loadingDropdowns || !watchedModel || trims.length === 0}
>
<option value="">Select Trim</option>
{trims.map((trim) => (
<option key={trim.id} value={trim.name}>
{trim.name}
</option>
))}
</select>
</div>
{/* Engine (middle) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Engine
@@ -169,7 +376,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<select
{...register('engine')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={loadingDropdowns}
disabled={loadingDropdowns || !watchedModel || !selectedTrim || engines.length === 0}
>
<option value="">Select Engine</option>
{engines.map((engine) => (
@@ -180,6 +387,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</select>
</div>
{/* Transmission (right, static options) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Transmission
@@ -187,32 +395,10 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<select
{...register('transmission')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={loadingDropdowns}
>
<option value="">Select Transmission</option>
{transmissions.map((transmission) => (
<option key={transmission.id} value={transmission.name}>
{transmission.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Trim Level
</label>
<select
{...register('trimLevel')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={loadingDropdowns}
>
<option value="">Select Trim</option>
{trims.map((trim) => (
<option key={trim.id} value={trim.name}>
{trim.name}
</option>
))}
<option value="Automatic">Automatic</option>
<option value="Manual">Manual</option>
</select>
</div>
</div>
@@ -247,8 +433,11 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<input
{...register('licensePlate')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="e.g., ABC-123"
placeholder="e.g., ABC-123 (required if VIN omitted)"
/>
{errors.licensePlate && (
<p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>
)}
</div>
</div>
@@ -274,4 +463,4 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</div>
</form>
);
};
};

View File

@@ -45,7 +45,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
onLogFuel
}) => {
const displayName = vehicle.nickname ||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
const displayModel = vehicle.model || 'Unknown Model';
return (

View File

@@ -32,7 +32,7 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
compact = false
}) => {
const displayName = vehicle.nickname ||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
const displayModel = vehicle.model || 'Unknown Model';
return (

View File

@@ -4,7 +4,8 @@
*/
import React, { useTransition, useEffect } from 'react';
import { Box, Typography, Grid } from '@mui/material';
import { Box, Typography, Grid, Fab } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import { useVehicles } from '../hooks/useVehicles';
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
@@ -14,6 +15,7 @@ import { Vehicle } from '../types/vehicles.types';
interface VehiclesMobileScreenProps {
onVehicleSelect?: (vehicle: Vehicle) => void;
onAddVehicle?: () => void;
}
const Section: React.FC<{ title: string; children: React.ReactNode; right?: React.ReactNode }> = ({
@@ -33,7 +35,8 @@ const Section: React.FC<{ title: string; children: React.ReactNode; right?: Reac
);
export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
onVehicleSelect
onVehicleSelect,
onAddVehicle
}) => {
const { data: vehicles, isLoading } = useVehicles();
const [_isPending, startTransition] = useTransition();
@@ -66,7 +69,12 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
return (
<Box sx={{ pb: 10 }}>
<Box sx={{ textAlign: 'center', py: 12 }}>
<Typography color="text.secondary">Loading vehicles...</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Loading your vehicles...
</Typography>
<Typography variant="caption" color="text.secondary">
Please wait a moment
</Typography>
</Box>
</Box>
);
@@ -74,7 +82,7 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
if (!optimisticVehicles.length) {
return (
<Box sx={{ pb: 10 }}>
<Box sx={{ pb: 10, position: 'relative' }}>
<Section title="Vehicles">
<Box sx={{ textAlign: 'center', py: 12 }}>
<Typography color="text.secondary" sx={{ mb: 2 }}>
@@ -85,13 +93,27 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
</Typography>
</Box>
</Section>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 80, // Above bottom navigation
right: 16,
zIndex: 1000
}}
onClick={() => onAddVehicle?.()}
>
<AddIcon />
</Fab>
</Box>
);
}
return (
<MobileVehiclesSuspense>
<Box sx={{ pb: 10 }}>
<Box sx={{ pb: 10, position: 'relative' }}>
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
<Grid container spacing={2}>
{filteredVehicles.map((vehicle) => (
@@ -104,6 +126,20 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
))}
</Grid>
</Section>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 80, // Above bottom navigation
right: 16,
zIndex: 1000
}}
onClick={() => onAddVehicle?.()}
>
<AddIcon />
</Fab>
</Box>
</MobileVehiclesSuspense>
);

View File

@@ -0,0 +1,255 @@
/**
* @ai-summary Vehicle detail page matching VehicleForm styling
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Button as MuiButton, Divider } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import EditIcon from '@mui/icons-material/Edit';
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
import BuildIcon from '@mui/icons-material/Build';
import { Vehicle } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
import { Card } from '../../../shared-minimal/components/Card';
import { VehicleForm } from '../components/VehicleForm';
const DetailField: React.FC<{
label: string;
value?: string | number;
isRequired?: boolean;
className?: string;
}> = ({ label, value, isRequired, className = "" }) => (
<div className={`space-y-1 ${className}`}>
<label className="block text-sm font-medium text-gray-700">
{label} {isRequired && <span className="text-red-500">*</span>}
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
<span className="text-gray-900">
{value || <span className="text-gray-400 italic">Not provided</span>}
</span>
</div>
</div>
);
export const VehicleDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadVehicle = async () => {
if (!id) return;
try {
setIsLoading(true);
const vehicleData = await vehiclesApi.getById(id);
setVehicle(vehicleData);
} catch (err) {
setError('Failed to load vehicle details');
console.error('Error loading vehicle:', err);
} finally {
setIsLoading(false);
}
};
loadVehicle();
}, [id]);
const handleBack = () => {
navigate('/vehicles');
};
const handleEdit = () => {
setIsEditing(true);
};
const handleUpdateVehicle = async (data: any) => {
if (!vehicle) return;
try {
const updatedVehicle = await vehiclesApi.update(vehicle.id, data);
setVehicle(updatedVehicle);
setIsEditing(false);
} catch (err) {
console.error('Error updating vehicle:', err);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
};
if (isLoading) {
return (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '50vh'
}}>
<Typography color="text.secondary">Loading vehicle details...</Typography>
</Box>
);
}
if (error || !vehicle) {
return (
<Box sx={{ py: 2 }}>
<Card>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="error.main" sx={{ mb: 3 }}>
{error || 'Vehicle not found'}
</Typography>
<MuiButton
variant="outlined"
onClick={handleBack}
startIcon={<ArrowBackIcon />}
>
Back to Vehicles
</MuiButton>
</Box>
</Card>
</Box>
);
}
const displayName = vehicle.nickname ||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
if (isEditing) {
return (
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
alignItems: 'center',
mb: 4
}}>
<MuiButton
variant="text"
startIcon={<ArrowBackIcon />}
onClick={handleCancelEdit}
sx={{ mr: 2 }}
>
Cancel
</MuiButton>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
Edit {displayName}
</Typography>
</Box>
<Card>
<VehicleForm
initialData={vehicle}
onSubmit={handleUpdateVehicle}
onCancel={handleCancelEdit}
/>
</Card>
</Box>
);
}
return (
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MuiButton
variant="text"
startIcon={<ArrowBackIcon />}
onClick={handleBack}
sx={{ mr: 2 }}
>
Back
</MuiButton>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
{displayName}
</Typography>
</Box>
<MuiButton
variant="contained"
startIcon={<EditIcon />}
onClick={handleEdit}
sx={{ borderRadius: '999px' }}
>
Edit Vehicle
</MuiButton>
</Box>
<Box sx={{ display: 'flex', gap: 2, mb: 4 }}>
<MuiButton
variant="contained"
startIcon={<LocalGasStationIcon />}
sx={{ borderRadius: '999px' }}
>
Add Fuel Log
</MuiButton>
<MuiButton
variant="outlined"
startIcon={<BuildIcon />}
sx={{ borderRadius: '999px' }}
>
Schedule Maintenance
</MuiButton>
</Box>
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Vehicle Details
</Typography>
<form className="space-y-4">
<DetailField
label="VIN or License Plate"
value={vehicle.vin || vehicle.licensePlate}
isRequired
/>
{/* Vehicle Specification Section */}
<div className="grid grid-cols-3 gap-4">
<DetailField label="Year" value={vehicle.year} />
<DetailField label="Make" value={vehicle.make} />
<DetailField label="Model" value={vehicle.model} />
</div>
<div className="grid grid-cols-3 gap-4">
<DetailField label="Trim" value={vehicle.trimLevel} />
<DetailField label="Engine" value={vehicle.engine} />
<DetailField label="Transmission" value={vehicle.transmission} />
</div>
<DetailField label="Nickname" value={vehicle.nickname} />
<div className="grid grid-cols-2 gap-4">
<DetailField label="Color" value={vehicle.color} />
<DetailField label="License Plate" value={vehicle.licensePlate} />
</div>
<DetailField
label="Current Odometer Reading"
value={vehicle.odometerReading ? `${vehicle.odometerReading.toLocaleString()} mi` : undefined}
/>
</form>
<Divider sx={{ my: 4 }} />
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Vehicle Information
</Typography>
<Box sx={{ display: 'flex', gap: 4, color: 'text.secondary', fontSize: '0.875rem' }}>
<span>Added: {new Date(vehicle.createdAt).toLocaleDateString()}</span>
{vehicle.updatedAt !== vehicle.createdAt && (
<span>Last updated: {new Date(vehicle.updatedAt).toLocaleDateString()}</span>
)}
</Box>
</Card>
</Box>
);
};

View File

@@ -51,7 +51,8 @@ export const VehiclesPage: React.FC = () => {
const handleSelectVehicle = (id: string) => {
// Use transition for navigation to avoid blocking UI
startTransition(() => {
setSelectedVehicle(id);
const vehicle = optimisticVehicles.find(v => v.id === id);
setSelectedVehicle(vehicle || null);
navigate(`/vehicles/${id}`);
});
};

View File

@@ -25,6 +25,7 @@ export interface Vehicle {
export interface CreateVehicleRequest {
vin: string;
year?: number;
make?: string;
model?: string;
engine?: string;
@@ -55,4 +56,17 @@ export interface UpdateVehicleRequest {
export interface DropdownOption {
id: number;
name: string;
}
export interface VINDecodeResponse {
vin: string;
success: boolean;
year?: number;
make?: string;
model?: string;
trimLevel?: string;
engine?: string;
transmission?: string;
confidence?: number;
error?: string;
}

View File

@@ -5,20 +5,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { Auth0Provider } from './core/auth/Auth0Provider';
import { createEnhancedQueryClient } from './core/query/query-config';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
const queryClient = createEnhancedQueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@@ -0,0 +1,271 @@
/**
* @ai-summary Settings page component for desktop application
*/
import React, { useState } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useUnits } from '../core/units/UnitsContext';
import {
Box,
Typography,
Switch,
Divider,
Avatar,
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemSecondaryAction,
Button as MuiButton,
Select,
MenuItem,
FormControl
} from '@mui/material';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import NotificationsIcon from '@mui/icons-material/Notifications';
import PaletteIcon from '@mui/icons-material/Palette';
import SecurityIcon from '@mui/icons-material/Security';
import StorageIcon from '@mui/icons-material/Storage';
import { Card } from '../shared-minimal/components/Card';
export const SettingsPage: React.FC = () => {
const { user, logout } = useAuth0();
const { unitSystem, setUnitSystem } = useUnits();
const [notifications, setNotifications] = useState(true);
const [emailUpdates, setEmailUpdates] = useState(false);
const [darkMode, setDarkMode] = useState(false);
const handleLogout = () => {
logout({ logoutParams: { returnTo: window.location.origin } });
};
return (
<Box sx={{ py: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
Settings
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Account Section */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Account
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
<Avatar
sx={{
width: 64,
height: 64,
bgcolor: 'primary.main',
fontSize: '1.5rem',
fontWeight: 600,
mr: 3
}}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
<Box>
<Typography variant="h6" sx={{ fontWeight: 500 }}>
{user?.name || 'User'}
</Typography>
<Typography variant="body2" color="text.secondary">
{user?.email}
</Typography>
<Typography variant="caption" color="text.secondary">
Verified account
</Typography>
</Box>
</Box>
<List disablePadding>
<ListItem>
<ListItemIcon>
<AccountCircleIcon />
</ListItemIcon>
<ListItemText
primary="Profile Information"
secondary="Manage your account details"
/>
<ListItemSecondaryAction>
<MuiButton variant="outlined" size="small">
Edit
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemIcon>
<SecurityIcon />
</ListItemIcon>
<ListItemText
primary="Security & Privacy"
secondary="Password, two-factor authentication"
/>
<ListItemSecondaryAction>
<MuiButton variant="outlined" size="small">
Manage
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
</List>
</Card>
{/* Notifications Section */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Notifications
</Typography>
<List disablePadding>
<ListItem>
<ListItemIcon>
<NotificationsIcon />
</ListItemIcon>
<ListItemText
primary="Push Notifications"
secondary="Receive notifications about your vehicles"
/>
<ListItemSecondaryAction>
<Switch
checked={notifications}
onChange={(e) => setNotifications(e.target.checked)}
color="primary"
/>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Email Updates"
secondary="Receive maintenance reminders and updates"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<Switch
checked={emailUpdates}
onChange={(e) => setEmailUpdates(e.target.checked)}
color="primary"
/>
</ListItemSecondaryAction>
</ListItem>
</List>
</Card>
{/* Appearance & Units Section */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Appearance & Units
</Typography>
<List disablePadding>
<ListItem>
<ListItemIcon>
<PaletteIcon />
</ListItemIcon>
<ListItemText
primary="Dark Mode"
secondary="Use dark theme for better night viewing"
/>
<ListItemSecondaryAction>
<Switch
checked={darkMode}
onChange={(e) => setDarkMode(e.target.checked)}
color="primary"
/>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Units for distance and capacity"
secondary="Choose between Imperial (miles, gallons) or Metric (kilometers, liters)"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={unitSystem}
onChange={(e) => setUnitSystem(e.target.value as 'imperial' | 'metric')}
displayEmpty
sx={{
fontSize: '0.875rem',
'& .MuiSelect-select': {
py: 1
}
}}
>
<MenuItem value="imperial">Imperial</MenuItem>
<MenuItem value="metric">Metric</MenuItem>
</Select>
</FormControl>
</ListItemSecondaryAction>
</ListItem>
</List>
</Card>
{/* Data & Storage Section */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Data & Storage
</Typography>
<List disablePadding>
<ListItem>
<ListItemIcon>
<StorageIcon />
</ListItemIcon>
<ListItemText
primary="Export Data"
secondary="Download your vehicle and fuel log data"
/>
<ListItemSecondaryAction>
<MuiButton variant="outlined" size="small">
Export
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Clear Cache"
secondary="Remove cached data to free up space"
sx={{ pl: 7 }}
/>
<ListItemSecondaryAction>
<MuiButton variant="outlined" size="small" color="warning">
Clear
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
</List>
</Card>
{/* Account Actions */}
<Card>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'error.main' }}>
Account Actions
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<MuiButton
variant="contained"
color="error"
onClick={handleLogout}
sx={{ borderRadius: '999px' }}
>
Sign Out
</MuiButton>
<MuiButton
variant="outlined"
color="error"
sx={{ borderRadius: '999px' }}
>
Delete Account
</MuiButton>
</Box>
</Card>
</Box>
</Box>
);
};