MVP with new UX
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
Load .ai/context.json to understand the project's danger zones and loading strategies
|
||||||
|
|
||||||
|
Load AI_README.md and @PROJECT_MAP.md to gain context on the application.
|
||||||
|
|
||||||
CRITICAL: All development practices and choices should be made taking into account the most context effecient interation with another AI. Any AI should be able to understand this applicaiton with minimal prompting.
|
CRITICAL: All development practices and choices should be made taking into account the most context effecient interation with another AI. Any AI should be able to understand this applicaiton with minimal prompting.
|
||||||
CRITICAL: All development/testing happens in Docker containers
|
CRITICAL: All development/testing happens in Docker containers
|
||||||
no local package installations:
|
no local package installations:
|
||||||
|
|||||||
@@ -24,7 +24,12 @@
|
|||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"date-fns": "^3.0.0",
|
"date-fns": "^3.0.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"react-hot-toast": "^2.4.1"
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"framer-motion": "^10.16.16",
|
||||||
|
"@mui/material": "^5.15.0",
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/icons-material": "^5.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.42",
|
"@types/react": "^18.2.42",
|
||||||
|
|||||||
@@ -1,50 +1,238 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Main app component with routing
|
* @ai-summary Main app component with routing and mobile navigation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
|
||||||
|
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
|
||||||
|
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
|
||||||
|
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
|
||||||
|
import { md3Theme } from './shared-minimal/theme/md3Theme';
|
||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { VehiclesPage } from './features/vehicles/pages/VehiclesPage';
|
import { VehiclesPage } from './features/vehicles/pages/VehiclesPage';
|
||||||
|
import { VehiclesMobileScreen } from './features/vehicles/mobile/VehiclesMobileScreen';
|
||||||
|
import { VehicleDetailMobile } from './features/vehicles/mobile/VehicleDetailMobile';
|
||||||
|
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
|
||||||
|
import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
|
||||||
import { Button } from './shared-minimal/components/Button';
|
import { Button } from './shared-minimal/components/Button';
|
||||||
|
import { Vehicle } from './features/vehicles/types/vehicles.types';
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0();
|
const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0();
|
||||||
|
|
||||||
|
// Mobile navigation state - 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);
|
||||||
|
|
||||||
|
// Update mobile mode on window resize
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMobileMode = () => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check on mount
|
||||||
|
checkMobileMode();
|
||||||
|
|
||||||
|
window.addEventListener('resize', checkMobileMode);
|
||||||
|
return () => window.removeEventListener('resize', checkMobileMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mobile navigation items
|
||||||
|
const mobileNavItems: NavigationItem[] = [
|
||||||
|
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
|
||||||
|
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> },
|
||||||
|
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> },
|
||||||
|
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, userAgent: navigator.userAgent });
|
||||||
|
|
||||||
|
// Debug component for testing
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Placeholder screens for mobile
|
||||||
|
const DashboardScreen = () => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<GlassCard>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-800 mb-2">Dashboard</h2>
|
||||||
|
<p className="text-slate-500">Coming soon - Vehicle insights and analytics</p>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</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 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>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
if (mobileMode) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={md3Theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Layout mobileMode={true}>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-slate-500">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<ThemeProvider theme={md3Theme}>
|
||||||
<div className="text-lg">Loading...</div>
|
<CssBaseline />
|
||||||
</div>
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-lg">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
if (mobileMode) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={md3Theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Layout mobileMode={true}>
|
||||||
|
<div className="space-y-6 flex flex-col items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800 mb-3">Welcome to MotoVaultPro</h1>
|
||||||
|
<p className="text-slate-600 mb-6 text-sm">Your personal vehicle management platform</p>
|
||||||
|
<button
|
||||||
|
onClick={() => loginWithRedirect()}
|
||||||
|
className="h-12 px-8 rounded-2xl text-white font-medium shadow-lg active:scale-[0.99] transition bg-gradient-moto"
|
||||||
|
>
|
||||||
|
Login to Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DebugInfo />
|
||||||
|
</Layout>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
<ThemeProvider theme={md3Theme}>
|
||||||
<div className="text-center">
|
<CssBaseline />
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">MotoVaultPro</h1>
|
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
<p className="text-gray-600 mb-8">Your personal vehicle management platform</p>
|
<div className="text-center max-w-md mx-auto px-6">
|
||||||
<Button onClick={() => loginWithRedirect()}>
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">MotoVaultPro</h1>
|
||||||
Login to Continue
|
<p className="text-gray-600 mb-8">Your personal vehicle management platform</p>
|
||||||
</Button>
|
<Button onClick={() => loginWithRedirect()}>
|
||||||
|
Login to Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DebugInfo />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile app rendering
|
||||||
|
if (mobileMode) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={md3Theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Layout mobileMode={true}>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{activeScreen === "Dashboard" && (
|
||||||
|
<motion.div key="dashboard" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||||
|
<DashboardScreen />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{activeScreen === "Settings" && (
|
||||||
|
<motion.div key="settings" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
||||||
|
<SettingsScreen />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<DebugInfo />
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<BottomNavigation
|
||||||
|
items={mobileNavItems}
|
||||||
|
activeItem={activeScreen}
|
||||||
|
onItemSelect={setActiveScreen}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop app rendering (fallback)
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<ThemeProvider theme={md3Theme}>
|
||||||
<Routes>
|
<CssBaseline />
|
||||||
<Route path="/" element={<Navigate to="/vehicles" replace />} />
|
<Layout mobileMode={false}>
|
||||||
<Route path="/vehicles" element={<VehiclesPage />} />
|
<Routes>
|
||||||
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
|
<Route path="/" element={<Navigate to="/vehicles" replace />} />
|
||||||
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
|
<Route path="/vehicles" element={<VehiclesPage />} />
|
||||||
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
|
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
|
||||||
<Route path="/stations" element={<div>Stations (TODO)</div>} />
|
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
|
||||||
<Route path="*" element={<Navigate to="/vehicles" replace />} />
|
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
|
||||||
</Routes>
|
<Route path="/stations" element={<div>Stations (TODO)</div>} />
|
||||||
</Layout>
|
<Route path="*" element={<Navigate to="/vehicles" replace />} />
|
||||||
|
</Routes>
|
||||||
|
<DebugInfo />
|
||||||
|
</Layout>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,130 +1,244 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Main layout component with navigation
|
* @ai-summary Main layout component with navigation (desktop/mobile adaptive)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAuth0 } from '@auth0/auth0-react';
|
import { useAuth0 } from '@auth0/auth0-react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
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 MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { useAppStore } from '../core/store';
|
import { useAppStore } from '../core/store';
|
||||||
import { Button } from '../shared-minimal/components/Button';
|
import { Button } from '../shared-minimal/components/Button';
|
||||||
import { clsx } from 'clsx';
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
mobileMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => {
|
||||||
const { user, logout } = useAuth0();
|
const { user, logout } = useAuth0();
|
||||||
const { sidebarOpen, toggleSidebar } = useAppStore();
|
const { sidebarOpen, toggleSidebar } = useAppStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Vehicles', href: '/vehicles', icon: '🚗' },
|
{ name: 'Vehicles', href: '/vehicles', icon: <DirectionsCarRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||||
{ name: 'Fuel Logs', href: '/fuel-logs', icon: '⛽' },
|
{ name: 'Fuel Logs', href: '/fuel-logs', icon: <LocalGasStationRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||||
{ name: 'Maintenance', href: '/maintenance', icon: '🔧' },
|
{ name: 'Maintenance', href: '/maintenance', icon: <BuildRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||||
{ name: 'Gas Stations', href: '/stations', icon: '🏪' },
|
{ name: 'Gas Stations', href: '/stations', icon: <PlaceRoundedIcon sx={{ fontSize: 20 }} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
// Mobile layout
|
||||||
<div className="min-h-screen bg-gray-50">
|
if (mobileMode) {
|
||||||
{/* Sidebar */}
|
return (
|
||||||
<div className={clsx(
|
<div className="w-full min-h-screen bg-background-default">
|
||||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-200 ease-in-out',
|
<Container
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
maxWidth={false}
|
||||||
)}>
|
sx={{
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
bgcolor: 'background.paper',
|
||||||
<h1 className="text-xl font-bold text-gray-900">MotoVaultPro</h1>
|
borderRadius: 0,
|
||||||
<button
|
p: 0,
|
||||||
onClick={toggleSidebar}
|
boxShadow: 0,
|
||||||
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
|
minHeight: '100vh',
|
||||||
>
|
display: 'flex',
|
||||||
<span className="sr-only">Close sidebar</span>
|
flexDirection: 'column',
|
||||||
✕
|
width: '100%',
|
||||||
</button>
|
maxWidth: '100% !important'
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
{/* App header */}
|
||||||
|
<div className="px-5 pt-5 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-lg font-semibold tracking-tight">MotoVaultPro</div>
|
||||||
|
<div className="text-xs text-slate-500">v0.1</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="flex-1 px-5 pb-20 space-y-5 overflow-y-auto">
|
||||||
|
<div className="min-h-[560px]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<nav className="mt-6">
|
// Desktop layout
|
||||||
<div className="px-3">
|
return (
|
||||||
{navigation.map((item) => (
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
height: '100vh',
|
||||||
|
width: 256,
|
||||||
|
zIndex: 1000,
|
||||||
|
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'
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, color: 'primary.main' }}>
|
||||||
|
MotoVaultPro
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
size="small"
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3, px: 2, flex: 1 }}>
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = location.pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={clsx(
|
style={{ textDecoration: 'none' }}
|
||||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-md mb-1 transition-colors',
|
|
||||||
location.pathname.startsWith(item.href)
|
|
||||||
? 'bg-primary-50 text-primary-700'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="mr-3 text-lg">{item.icon}</span>
|
<Box
|
||||||
{item.name}
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
mb: 0.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
backgroundColor: isActive
|
||||||
|
? theme.palette.primary.main + '12'
|
||||||
|
: 'transparent',
|
||||||
|
color: isActive
|
||||||
|
? 'primary.main'
|
||||||
|
: 'text.primary',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: isActive
|
||||||
|
? theme.palette.primary.main + '18'
|
||||||
|
: 'action.hover',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ mr: 2, display: 'flex', alignItems: 'center' }}>
|
||||||
|
{item.icon}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{item.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</nav>
|
</Box>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}>
|
||||||
<div className="flex items-center">
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
<div className="flex-shrink-0">
|
<Avatar
|
||||||
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
sx={{
|
||||||
<span className="text-primary-600 font-medium text-sm">
|
width: 32,
|
||||||
{user?.name?.charAt(0) || user?.email?.charAt(0)}
|
height: 32,
|
||||||
</span>
|
bgcolor: 'primary.main',
|
||||||
</div>
|
fontSize: '0.875rem',
|
||||||
</div>
|
fontWeight: 600
|
||||||
<div className="ml-3 flex-1 min-w-0">
|
}}
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">
|
>
|
||||||
|
{user?.name?.charAt(0) || user?.email?.charAt(0)}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
|
||||||
{user?.name || user?.email}
|
{user?.name || user?.email}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full mt-3"
|
className="w-full"
|
||||||
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
|
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Paper>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className={clsx(
|
<Box
|
||||||
'transition-all duration-200 ease-in-out',
|
sx={{
|
||||||
sidebarOpen ? 'ml-64' : 'ml-0'
|
ml: sidebarOpen ? '256px' : '0',
|
||||||
)}>
|
transition: 'margin-left 0.2s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
<Paper
|
||||||
<div className="flex items-center justify-between h-16 px-6">
|
elevation={1}
|
||||||
<button
|
sx={{
|
||||||
|
borderRadius: 0,
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: 64,
|
||||||
|
px: 3
|
||||||
|
}}>
|
||||||
|
<IconButton
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
|
sx={{ color: 'text.secondary' }}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Open sidebar</span>
|
<MenuIcon />
|
||||||
☰
|
</IconButton>
|
||||||
</button>
|
<Typography variant="body2" color="text.secondary">
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Welcome back, {user?.name || user?.email}
|
Welcome back, {user?.name || user?.email}
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</header>
|
</Paper>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="p-6">
|
<Box component="main" sx={{ p: 3 }}>
|
||||||
<div className="max-w-7xl mx-auto">
|
<Container maxWidth="xl">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Container>
|
||||||
</main>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<Box
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 999,
|
||||||
|
bgcolor: 'rgba(0,0,0,0.5)',
|
||||||
|
display: { lg: 'none' }
|
||||||
|
}}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Vehicle card component
|
* @ai-summary Vehicle card component with Material Design 3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardActionArea, Box, Typography, IconButton } from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
import { Card } from '../../../shared-minimal/components/Card';
|
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
|
||||||
|
|
||||||
interface VehicleCardProps {
|
interface VehicleCardProps {
|
||||||
vehicle: Vehicle;
|
vehicle: Vehicle;
|
||||||
@@ -14,51 +15,96 @@ interface VehicleCardProps {
|
|||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 96,
|
||||||
|
bgcolor: color,
|
||||||
|
borderRadius: 2,
|
||||||
|
mb: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const VehicleCard: React.FC<VehicleCardProps> = ({
|
export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||||
vehicle,
|
vehicle,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const displayName = vehicle.nickname ||
|
||||||
|
`${vehicle.year} ${vehicle.make} ${vehicle.model}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => onSelect(vehicle.id)}>
|
<Card
|
||||||
<div className="flex justify-between items-start">
|
sx={{
|
||||||
<div className="flex-1">
|
height: '100%',
|
||||||
<h3 className="text-lg font-semibold text-gray-900">
|
display: 'flex',
|
||||||
{vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`}
|
flexDirection: 'column',
|
||||||
</h3>
|
'&:hover': {
|
||||||
<p className="text-sm text-gray-500 mt-1">VIN: {vehicle.vin}</p>
|
boxShadow: 3,
|
||||||
|
},
|
||||||
|
transition: 'box-shadow 0.2s ease-in-out'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardActionArea
|
||||||
|
onClick={() => onSelect(vehicle.id)}
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
||||||
|
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||||
|
VIN: {vehicle.vin}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
{vehicle.licensePlate && (
|
{vehicle.licensePlate && (
|
||||||
<p className="text-sm text-gray-500">License: {vehicle.licensePlate}</p>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||||
|
License: {vehicle.licensePlate}
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
|
||||||
|
<Typography variant="body2" color="text.primary" sx={{ mt: 1, fontWeight: 500 }}>
|
||||||
Odometer: {vehicle.odometerReading.toLocaleString()} miles
|
Odometer: {vehicle.odometerReading.toLocaleString()} miles
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
<Box sx={{
|
||||||
size="sm"
|
display: 'flex',
|
||||||
variant="secondary"
|
justifyContent: 'flex-end',
|
||||||
onClick={(e) => {
|
gap: 1,
|
||||||
e.stopPropagation();
|
p: 2,
|
||||||
onEdit(vehicle);
|
pt: 0
|
||||||
}}
|
}}>
|
||||||
>
|
<IconButton
|
||||||
Edit
|
size="small"
|
||||||
</Button>
|
onClick={(e) => {
|
||||||
<Button
|
e.stopPropagation();
|
||||||
size="sm"
|
onEdit(vehicle);
|
||||||
variant="danger"
|
}}
|
||||||
onClick={(e) => {
|
sx={{ color: 'text.secondary' }}
|
||||||
e.stopPropagation();
|
>
|
||||||
onDelete(vehicle.id);
|
<EditIcon fontSize="small" />
|
||||||
}}
|
</IconButton>
|
||||||
>
|
<IconButton
|
||||||
Delete
|
size="small"
|
||||||
</Button>
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
</div>
|
onDelete(vehicle.id);
|
||||||
|
}}
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
138
frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx
Normal file
138
frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Mobile vehicle detail screen with Material Design 3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Typography, Button, Card, CardContent, Divider } from '@mui/material';
|
||||||
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
|
|
||||||
|
// Theme colors now defined in Tailwind config
|
||||||
|
|
||||||
|
interface VehicleDetailMobileProps {
|
||||||
|
vehicle: Vehicle;
|
||||||
|
onBack: () => void;
|
||||||
|
onLogFuel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||||
|
title,
|
||||||
|
children
|
||||||
|
}) => (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 96,
|
||||||
|
bgcolor: color,
|
||||||
|
borderRadius: 3,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||||
|
vehicle,
|
||||||
|
onBack,
|
||||||
|
onLogFuel
|
||||||
|
}) => {
|
||||||
|
const displayName = vehicle.nickname ||
|
||||||
|
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
|
||||||
|
const displayModel = vehicle.model || 'Unknown Model';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Button variant="text" onClick={onBack}>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" sx={{ mt: 1, mb: 2 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 3 }}>
|
||||||
|
<Box sx={{ width: 112 }}>
|
||||||
|
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">{displayModel}</Typography>
|
||||||
|
{vehicle.licensePlate && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{vehicle.licensePlate}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, mb: 3 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onLogFuel}
|
||||||
|
>
|
||||||
|
Add Fuel
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined">
|
||||||
|
Maintenance
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Section title="Vehicle Details">
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{vehicle.vin && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography color="text.secondary">VIN</Typography>
|
||||||
|
<Typography sx={{ fontFamily: 'monospace', fontSize: 'small' }}>
|
||||||
|
{vehicle.vin}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{vehicle.year && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography color="text.secondary">Year</Typography>
|
||||||
|
<Typography>{vehicle.year}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{vehicle.make && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography color="text.secondary">Make</Typography>
|
||||||
|
<Typography>{vehicle.make}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{vehicle.model && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography color="text.secondary">Model</Typography>
|
||||||
|
<Typography>{vehicle.model}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Typography color="text.secondary">Odometer</Typography>
|
||||||
|
<Typography>{vehicle.odometerReading.toLocaleString()} mi</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Recent Activity">
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
No recent activity
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx
Normal file
65
frontend/src/features/vehicles/mobile/VehicleMobileCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Mobile-optimized vehicle card component with Material Design 3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, CardActionArea, Box, Typography } from '@mui/material';
|
||||||
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
|
|
||||||
|
interface VehicleMobileCardProps {
|
||||||
|
vehicle: Vehicle;
|
||||||
|
onClick?: (vehicle: Vehicle) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 120,
|
||||||
|
bgcolor: color,
|
||||||
|
borderRadius: 3,
|
||||||
|
mb: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
|
||||||
|
vehicle,
|
||||||
|
onClick,
|
||||||
|
compact = false
|
||||||
|
}) => {
|
||||||
|
const displayName = vehicle.nickname ||
|
||||||
|
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
|
||||||
|
const displayModel = vehicle.model || 'Unknown Model';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
borderRadius: 18,
|
||||||
|
overflow: 'hidden',
|
||||||
|
minWidth: compact ? 260 : 'auto',
|
||||||
|
width: compact ? 260 : '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardActionArea onClick={() => onClick?.(vehicle)}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{displayModel}
|
||||||
|
</Typography>
|
||||||
|
{vehicle.licensePlate && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
{vehicle.licensePlate}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Mobile-optimized vehicles screen with Material Design 3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Typography, Grid } from '@mui/material';
|
||||||
|
import { useVehicles } from '../hooks/useVehicles';
|
||||||
|
import { VehicleMobileCard } from './VehicleMobileCard';
|
||||||
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
|
|
||||||
|
interface VehiclesMobileScreenProps {
|
||||||
|
onVehicleSelect?: (vehicle: Vehicle) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section: React.FC<{ title: string; children: React.ReactNode; right?: React.ReactNode }> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
right
|
||||||
|
}) => (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{right}
|
||||||
|
</Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||||
|
onVehicleSelect
|
||||||
|
}) => {
|
||||||
|
const { data: vehicles, isLoading } = useVehicles();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||||
|
<Typography color="text.secondary">Loading vehicles...</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vehicles?.length) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Section title="Vehicles">
|
||||||
|
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||||
|
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
No vehicles added yet
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Add your first vehicle to get started
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Section title="Vehicles">
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{vehicles.map((vehicle) => (
|
||||||
|
<Grid item xs={12} key={vehicle.id}>
|
||||||
|
<VehicleMobileCard
|
||||||
|
vehicle={vehicle}
|
||||||
|
onClick={() => onVehicleSelect?.(vehicle)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Section>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Main vehicles page
|
* @ai-summary Main vehicles page with Material Design 3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Typography, Grid, Button as MuiButton } from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { useVehicles, useCreateVehicle, useDeleteVehicle } from '../hooks/useVehicles';
|
import { useVehicles, useCreateVehicle, useDeleteVehicle } from '../hooks/useVehicles';
|
||||||
import { VehicleCard } from '../components/VehicleCard';
|
import { VehicleCard } from '../components/VehicleCard';
|
||||||
import { VehicleForm } from '../components/VehicleForm';
|
import { VehicleForm } from '../components/VehicleForm';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
|
||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
import { useAppStore } from '../../../core/store';
|
import { useAppStore } from '../../../core/store';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -33,24 +34,45 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<Box sx={{
|
||||||
<div className="text-gray-500">Loading vehicles...</div>
|
display: 'flex',
|
||||||
</div>
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '50vh'
|
||||||
|
}}>
|
||||||
|
<Typography color="text.secondary">Loading vehicles...</Typography>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Box sx={{ py: 2 }}>
|
||||||
<div className="flex justify-between items-center">
|
<Box sx={{
|
||||||
<h1 className="text-2xl font-bold text-gray-900">My Vehicles</h1>
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 4
|
||||||
|
}}>
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||||
|
My Vehicles
|
||||||
|
</Typography>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<Button onClick={() => setShowForm(true)}>Add Vehicle</Button>
|
<MuiButton
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
sx={{ borderRadius: '999px' }}
|
||||||
|
>
|
||||||
|
Add Vehicle
|
||||||
|
</MuiButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Card>
|
<Card className="mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add New Vehicle</h2>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||||
|
Add New Vehicle
|
||||||
|
</Typography>
|
||||||
<VehicleForm
|
<VehicleForm
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
await createVehicle.mutateAsync(data);
|
await createVehicle.mutateAsync(data);
|
||||||
@@ -64,26 +86,36 @@ export const VehiclesPage: React.FC = () => {
|
|||||||
|
|
||||||
{vehicles?.length === 0 ? (
|
{vehicles?.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="text-center py-12">
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
<p className="text-gray-500 mb-4">No vehicles added yet</p>
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
No vehicles added yet
|
||||||
|
</Typography>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<Button onClick={() => setShowForm(true)}>Add Your First Vehicle</Button>
|
<MuiButton
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
sx={{ borderRadius: '999px' }}
|
||||||
|
>
|
||||||
|
Add Your First Vehicle
|
||||||
|
</MuiButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<Grid container spacing={3}>
|
||||||
{vehicles?.map((vehicle) => (
|
{vehicles?.map((vehicle) => (
|
||||||
<VehicleCard
|
<Grid item xs={12} md={6} lg={4} key={vehicle.id}>
|
||||||
key={vehicle.id}
|
<VehicleCard
|
||||||
vehicle={vehicle}
|
vehicle={vehicle}
|
||||||
onEdit={(v) => console.log('Edit', v)}
|
onEdit={(v) => console.log('Edit', v)}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onSelect={handleSelectVehicle}
|
onSelect={handleSelectVehicle}
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Reusable card component
|
* @ai-summary Reusable card component with Material Design 3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { clsx } from 'clsx';
|
import { Card as MuiCard, CardContent } from '@mui/material';
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -18,24 +18,27 @@ export const Card: React.FC<CardProps> = ({
|
|||||||
padding = 'md',
|
padding = 'md',
|
||||||
onClick,
|
onClick,
|
||||||
}) => {
|
}) => {
|
||||||
const paddings = {
|
const paddingStyles = {
|
||||||
none: '',
|
none: 0,
|
||||||
sm: 'p-3',
|
sm: 1,
|
||||||
md: 'p-4',
|
md: 2,
|
||||||
lg: 'p-6',
|
lg: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<MuiCard
|
||||||
className={clsx(
|
|
||||||
'bg-white rounded-lg shadow-sm border border-gray-200',
|
|
||||||
paddings[padding],
|
|
||||||
onClick && 'cursor-pointer',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
|
'&:hover': onClick ? {
|
||||||
|
boxShadow: 2
|
||||||
|
} : {}
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{children}
|
<CardContent sx={{ p: paddingStyles[padding] }}>
|
||||||
</div>
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</MuiCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Bottom navigation component with Material Design 3
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { BottomNavigation as MuiBottomNavigation, BottomNavigationAction } from '@mui/material';
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomNavigationProps {
|
||||||
|
items: NavigationItem[];
|
||||||
|
activeItem: string;
|
||||||
|
onItemSelect: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BottomNavigation: React.FC<BottomNavigationProps> = ({
|
||||||
|
items,
|
||||||
|
activeItem,
|
||||||
|
onItemSelect
|
||||||
|
}) => {
|
||||||
|
const activeIndex = items.findIndex(item => item.key === activeItem);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MuiBottomNavigation
|
||||||
|
showLabels
|
||||||
|
value={activeIndex}
|
||||||
|
onChange={(_, newValue) => onItemSelect(items[newValue].key)}
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map(({ key, label, icon }) => (
|
||||||
|
<BottomNavigationAction
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</MuiBottomNavigation>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
frontend/src/shared-minimal/components/mobile/GlassCard.tsx
Normal file
41
frontend/src/shared-minimal/components/mobile/GlassCard.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Glass morphism card component for mobile UI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface GlassCardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlassCard: React.FC<GlassCardProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
padding = 'md',
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const paddings = {
|
||||||
|
none: '',
|
||||||
|
sm: 'p-3',
|
||||||
|
md: 'p-4',
|
||||||
|
lg: 'p-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-3xl border border-slate-200/70 bg-white/80 shadow-sm backdrop-blur',
|
||||||
|
paddings[padding],
|
||||||
|
onClick && 'cursor-pointer hover:shadow-xl hover:-translate-y-0.5 transition',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Mobile app container with glass morphism styling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface MobileContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileContainer: React.FC<MobileContainerProps> = ({
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-start justify-center p-4 md:py-6">
|
||||||
|
<div className={`w-full max-w-[380px] min-h-screen md:min-h-[600px] md:rounded-[32px] shadow-2xl flex flex-col border-0 md:border border-slate-200/70 bg-white/90 md:bg-white/70 backdrop-blur-xl ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
frontend/src/shared-minimal/components/mobile/MobilePill.tsx
Normal file
40
frontend/src/shared-minimal/components/mobile/MobilePill.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Mobile pill button component with gradient styling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
// Theme colors now defined in Tailwind config
|
||||||
|
|
||||||
|
interface MobilePillProps {
|
||||||
|
active?: boolean;
|
||||||
|
label: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobilePill: React.FC<MobilePillProps> = ({
|
||||||
|
active = false,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
"group h-11 rounded-2xl text-sm font-medium border transition flex items-center justify-center gap-2 backdrop-blur",
|
||||||
|
active
|
||||||
|
? "text-white border-transparent shadow-lg bg-gradient-moto"
|
||||||
|
: "bg-white/80 text-slate-800 border-slate-200 hover:bg-slate-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
frontend/src/shared-minimal/theme/md3Theme.ts
Normal file
96
frontend/src/shared-minimal/theme/md3Theme.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Material Design 3 theme configuration for MotoVaultPro
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createTheme, alpha } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// Brand color from mockup
|
||||||
|
const primaryHex = '#7A212A';
|
||||||
|
|
||||||
|
export const md3Theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: primaryHex
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: alpha(primaryHex, 0.8)
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#F8F5F3',
|
||||||
|
paper: '#FFFFFF'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 16
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily:
|
||||||
|
'Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif',
|
||||||
|
h3: {
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: -0.5
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow:
|
||||||
|
'0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
variants: [
|
||||||
|
// Custom MD3-like "tonal" button
|
||||||
|
{
|
||||||
|
props: { variant: 'tonal' as any },
|
||||||
|
style: ({ theme }) => ({
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.12),
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
borderRadius: 999,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
paddingInline: 18,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.18)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
props: { variant: 'contained' },
|
||||||
|
style: ({ theme }) => ({
|
||||||
|
borderRadius: 999,
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 700,
|
||||||
|
paddingInline: 20,
|
||||||
|
boxShadow: '0 8px 24px ' + alpha(theme.palette.primary.main, 0.25),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MuiBottomNavigation: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderTop: '1px solid rgba(16,24,40,.08)',
|
||||||
|
background: alpha('#FFFFFF', 0.8),
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiBottomNavigationAction: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
minWidth: 0,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
'&.Mui-selected': {
|
||||||
|
color: primaryHex
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,13 +9,18 @@ export default {
|
|||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
50: '#eff6ff',
|
||||||
500: '#3b82f6',
|
500: '#7A212A',
|
||||||
600: '#2563eb',
|
600: '#7A212A',
|
||||||
700: '#1d4ed8',
|
700: '#9c2a36',
|
||||||
},
|
},
|
||||||
gray: {
|
gray: {
|
||||||
850: '#18202f',
|
850: '#18202f',
|
||||||
}
|
},
|
||||||
|
'moto-red': '#7A212A',
|
||||||
|
'moto-red-light': '#9c2a36',
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-moto': 'linear-gradient(90deg, #7A212A, #9c2a36)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
|
|
||||||
// Theme
|
|
||||||
const primary = "#7A212A"; // requested
|
|
||||||
const primaryLight = "#9c2a36";
|
|
||||||
|
|
||||||
// Icons
|
|
||||||
const IconDash = (props) => (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
|
||||||
<rect x="3" y="3" width="18" height="7" rx="2" strokeWidth="1.6"/>
|
|
||||||
<rect x="3" y="14" width="10" height="7" rx="2" strokeWidth="1.6"/>
|
|
||||||
<rect x="15" y="14" width="6" height="7" rx="2" strokeWidth="1.6"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
const IconCar = (props) => (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
|
||||||
<path d="M3 13l2-5a3 3 0 012.8-2h6.4A3 3 0 0117 8l2 5" strokeWidth="1.6"/>
|
|
||||||
<rect x="2" y="11" width="20" height="6" rx="2" strokeWidth="1.6"/>
|
|
||||||
<circle cx="7" cy="17" r="1.5"/>
|
|
||||||
<circle cx="17" cy="17" r="1.5"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
const IconFuel = (props) => (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
|
||||||
<path d="M4 5h8a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1z" strokeWidth="1.6"/>
|
|
||||||
<path d="M12 8h2l3 3v7a2 2 0 01-2 2h0" strokeWidth="1.6"/>
|
|
||||||
<circle cx="8" cy="9" r="1.2"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
const IconSettings = (props) => (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
|
|
||||||
<path d="M12 8a4 4 0 100 8 4 4 0 000-8z" strokeWidth="1.6"/>
|
|
||||||
<path d="M19.4 15a1.8 1.8 0 00.36 2l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.8 1.8 0 00-2-.36 1.8 1.8 0 00-1 1.6V22a2 2 0 01-4 0v-.06a1.8 1.8 0 00-1-1.6 1.8 1.8 0 00-2 .36l-.06.06A2 2 0 013.2 19.1l.06-.06a1.8 1.8 0 00.36-2 1.8 1.8 0 00-1.6-1H2a2 2 0 010-4h.06a1.8 1.8 0 001.6-1 1.8 1.8 0 00-.36-2l-.06-.06A2 2 0 013.8 4.1l.06.06a1.8 1.8 0 002 .36 1.8 1.8 0 001-1.6V2a2 2 0 014 0v.06a1.8 1.8 0 001 1.6 1.8 1.8 0 002-.36l.06-.06A2 2 0 0120.8 4.9l-.06.06a1.8 1.8 0 00-.36 2 1.8 1.8 0 001.6 1H22a2 2 0 010 4h-.06a1.8 1.8 0 00-1.6 1z" strokeWidth="1.2"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Visual
|
|
||||||
const CarThumb = ({ color = "#e5e7eb" }) => (
|
|
||||||
<svg viewBox="0 0 120 64" className="w-full h-24 rounded-2xl" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="g" x1="0" x2="1">
|
|
||||||
<stop offset="0" stopColor={color} stopOpacity="0.9" />
|
|
||||||
<stop offset="1" stopColor="#ffffff" stopOpacity="0.7" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="0" y="0" width="120" height="64" rx="16" fill="url(#g)" />
|
|
||||||
<rect x="16" y="28" width="72" height="20" rx="6" fill="#fff" opacity="0.9"/>
|
|
||||||
<circle cx="38" cy="54" r="6" fill="#0f172a"/>
|
|
||||||
<circle cx="78" cy="54" r="6" fill="#0f172a"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const vehiclesSeed = [
|
|
||||||
{ id: 1, year: 2020, make: "Toyota", model: "Camry", color: "#93c5fd" },
|
|
||||||
{ id: 2, year: 2018, make: "Ford", model: "F-150", color: "#a5b4fc" },
|
|
||||||
{ id: 3, year: 2022, make: "Honda", model: "CR-V", color: "#fbcfe8" },
|
|
||||||
{ id: 4, year: 2015, make: "Subaru", model: "Outback", color: "#86efac" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Section = ({ title, children, right }) => (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
|
|
||||||
{right}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Pill = ({ active, label, onClick, icon }) => (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`group h-11 rounded-2xl text-sm font-medium border transition flex items-center justify-center gap-2 backdrop-blur ${
|
|
||||||
active
|
|
||||||
? "text-white border-transparent shadow-lg"
|
|
||||||
: "bg-white/80 text-slate-800 border-slate-200 hover:bg-slate-50"
|
|
||||||
}`}
|
|
||||||
style={active ? { background: `linear-gradient(90deg, ${primary}, ${primaryLight})` } : {}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SparkBar = ({ values }) => {
|
|
||||||
const max = Math.max(...values, 1);
|
|
||||||
return (
|
|
||||||
<div className="flex items-end gap-1 h-16 w-full">
|
|
||||||
{values.map((v, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{ height: `${(v / max) * 100}%`, background: `linear-gradient(to top, ${primary}, ${primaryLight})` }}
|
|
||||||
className="flex-1 rounded-t"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatCard = ({ label, value, sub }) => (
|
|
||||||
<div className="rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-sm backdrop-blur">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
|
|
||||||
<div className="text-2xl font-semibold mt-1 text-slate-900">{value}</div>
|
|
||||||
{sub && <div className="text-xs mt-1 text-slate-500">{sub}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const VehicleCard = ({ v, onClick, compact=false }) => (
|
|
||||||
<button
|
|
||||||
onClick={() => onClick?.(v)}
|
|
||||||
className={`rounded-3xl border border-slate-200/70 bg-white/80 text-left ${compact ? "w-44 flex-shrink-0" : "w-full"} hover:shadow-xl hover:-translate-y-0.5 transition shadow-sm backdrop-blur`}
|
|
||||||
>
|
|
||||||
<div className="p-3">
|
|
||||||
<CarThumb color={v.color} />
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="text-base font-semibold tracking-tight text-slate-800">{v.year} {v.make}</div>
|
|
||||||
<div className="text-sm text-slate-500">{v.model}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const VehiclesScreen = ({ vehicles, onOpen }) => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Section title="Vehicles">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{vehicles.map((v) => (
|
|
||||||
<VehicleCard key={v.id} v={v} onClick={onOpen} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const RecentVehicles = ({ recent, onOpen }) => (
|
|
||||||
<Section title="Recent Vehicles" right={<div className="text-xs text-slate-500">Last used</div>}>
|
|
||||||
<div className="flex gap-3 overflow-x-auto no-scrollbar pb-1">
|
|
||||||
{recent.map((v) => (
|
|
||||||
<VehicleCard key={v.id} v={v} onClick={onOpen} compact />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DashboardScreen = ({ recent }) => (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<StatCard label="Fuel Spend (Mo)" value="$238" sub="↑ 6% vs last month" />
|
|
||||||
<StatCard label="Avg Price / gal" value="$3.76" sub="US gallons" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-sm backdrop-blur">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="text-sm font-medium text-slate-800">Fuel Spent This Month</div>
|
|
||||||
<div className="text-xs text-slate-500">Last 30 days</div>
|
|
||||||
</div>
|
|
||||||
<SparkBar values={[6,8,5,7,10,12,9,11,6,7,8,9]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-3xl border border-slate-200/70 bg-white/80 p-4 shadow-sm backdrop-blur">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="text-sm font-medium text-slate-800">Fuel Spend Per Vehicle</div>
|
|
||||||
<div className="text-xs text-slate-500">This month</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-3 text-sm text-slate-700">
|
|
||||||
{[
|
|
||||||
{ name: "Camry", val: 92 },
|
|
||||||
{ name: "F-150", val: 104 },
|
|
||||||
{ name: "CR‑V", val: 42 },
|
|
||||||
].map(({ name, val }) => (
|
|
||||||
<div key={name} className="space-y-1">
|
|
||||||
<div className="text-xs text-slate-500">{name}</div>
|
|
||||||
<div className="h-2 rounded bg-slate-200/70">
|
|
||||||
<div className="h-2 rounded" style={{ width: `${Math.min(val, 100)}%`, background: `linear-gradient(90deg, ${primary}, ${primaryLight})` }} />
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-slate-600">${val}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecentVehicles recent={recent} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Field = ({ label, children }) => (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-slate-600">{label}</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FuelForm = ({ units, vehicles, onSave }) => {
|
|
||||||
const [vehicleId, setVehicleId] = useState(vehicles[0]?.id || "");
|
|
||||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0,10));
|
|
||||||
const [odo, setOdo] = useState(15126);
|
|
||||||
const [qty, setQty] = useState(12.5);
|
|
||||||
const [price, setPrice] = useState(3.79);
|
|
||||||
const [octane, setOctane] = useState("87");
|
|
||||||
const dist = units.distance === "mi" ? "mi" : "km";
|
|
||||||
const fuel = units.fuel === "gal" ? "gal" : "L";
|
|
||||||
|
|
||||||
const inputCls = "w-full h-11 px-3 rounded-xl border border-slate-200 bg-white/80 backdrop-blur focus:outline-none focus:ring-2 focus:ring-[rgba(122,33,42,0.35)]";
|
|
||||||
const selectCls = inputCls;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={(e) => { e.preventDefault(); onSave?.({ vehicleId, date, odo, qty, price, octane }); }}
|
|
||||||
>
|
|
||||||
<Section title="Log Fuel">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<Field label="Vehicle">
|
|
||||||
<select value={vehicleId} onChange={(e)=>setVehicleId(e.target.value)} className={selectCls}>
|
|
||||||
{vehicles.map(v => (
|
|
||||||
<option key={v.id} value={v.id}>{`${v.year} ${v.make} ${v.model}`}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<Field label="Date">
|
|
||||||
<input type="date" value={date} onChange={(e)=>setDate(e.target.value)} className={inputCls}/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<Field label={`Odometer (${dist})`}>
|
|
||||||
<input type="number" value={odo} onChange={(e)=>setOdo(Number(e.target.value))} className={inputCls}/>
|
|
||||||
</Field>
|
|
||||||
<Field label={`Quantity (${fuel})`}>
|
|
||||||
<input type="number" step="0.01" value={qty} onChange={(e)=>setQty(Number(e.target.value))} className={inputCls}/>
|
|
||||||
</Field>
|
|
||||||
<Field label={`Price / ${fuel}`}>
|
|
||||||
<input type="number" step="0.01" value={price} onChange={(e)=>setPrice(Number(e.target.value))} className={inputCls}/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Octane (gasoline)">
|
|
||||||
<select value={octane} onChange={(e)=>setOctane(e.target.value)} className={selectCls}>
|
|
||||||
<option>85</option>
|
|
||||||
<option>87</option>
|
|
||||||
<option>89</option>
|
|
||||||
<option>91</option>
|
|
||||||
<option>93</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="mt-2 h-12 w-full rounded-2xl text-white font-medium shadow-lg active:scale-[0.99] transition"
|
|
||||||
style={{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})` }}
|
|
||||||
>
|
|
||||||
Save Fuel Log
|
|
||||||
</button>
|
|
||||||
</Section>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VehicleDetail = ({ vehicle, onBack, onLogFuel }) => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<button onClick={onBack} className="text-sm text-slate-500">← Back</button>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-28"><CarThumb color={vehicle.color} /></div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xl font-semibold">{vehicle.year} {vehicle.make}</div>
|
|
||||||
<div className="text-slate-500">{vehicle.model}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button onClick={onLogFuel} className="h-10 px-4 rounded-xl text-white text-sm font-medium shadow" style={{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})` }}>Add Fuel</button>
|
|
||||||
<button className="h-10 px-4 rounded-xl border border-slate-200 text-sm font-medium bg-white/80 backdrop-blur">Maintenance</button>
|
|
||||||
</div>
|
|
||||||
<Section title="Fuel Logs">
|
|
||||||
<div className="rounded-3xl border border-slate-200/70 divide-y bg-white/80 backdrop-blur shadow-sm">
|
|
||||||
{[{d:"Apr 24", odo:"15,126 mi"},{d:"Mar 13", odo:"14,300 mi"},{d:"Jan 10", odo:"14,055 mi"}].map((r,i)=>(
|
|
||||||
<div key={i} className="flex items-center justify-between px-4 py-3 text-sm">
|
|
||||||
<span>{r.d}</span><span className="text-slate-600">{r.odo}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SettingsScreen = ({ units, setUnits }) => (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Section title="Units">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-slate-600 mb-1">Distance</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{[ ["mi","Miles"], ["km","Kilometers"] ].map(([val,label])=> (
|
|
||||||
<button key={val} onClick={()=>setUnits(u=>({...u, distance: val}))} className="h-10 rounded-xl border text-sm bg-white/80 border-slate-200" style={units.distance===val?{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})`, color: '#fff', borderColor: 'transparent' }:{}}>{label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-slate-600 mb-1">Fuel</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{[ ["gal","US Gallons"], ["L","Liters"] ].map(([val,label])=> (
|
|
||||||
<button key={val} onClick={()=>setUnits(u=>({...u, fuel: val}))} className="h-10 rounded-xl border text-sm bg-white/80 border-slate-200" style={units.fuel===val?{ background: `linear-gradient(90deg, ${primary}, ${primaryLight})`, color: '#fff', borderColor: 'transparent' }:{}}>{label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const BottomNav = ({ active, setActive }) => (
|
|
||||||
<div className="grid grid-cols-4 gap-3 w-full p-4 border-t border-slate-200 bg-white/70 backdrop-blur sticky bottom-0">
|
|
||||||
{[
|
|
||||||
{ key: "Dashboard", icon: <IconDash className="w-4 h-4"/> },
|
|
||||||
{ key: "Vehicles", icon: <IconCar className="w-4 h-4"/> },
|
|
||||||
{ key: "Log Fuel", icon: <IconFuel className="w-4 h-4"/> },
|
|
||||||
{ key: "Settings", icon: <IconSettings className="w-4 h-4"/> },
|
|
||||||
].map(({ key, icon }) => (
|
|
||||||
<Pill
|
|
||||||
key={key}
|
|
||||||
label={key}
|
|
||||||
icon={icon}
|
|
||||||
active={active === key}
|
|
||||||
onClick={() => setActive(key)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [active, setActive] = useState("Dashboard");
|
|
||||||
const [units, setUnits] = useState({ distance: "mi", fuel: "gal" });
|
|
||||||
const [vehicles, setVehicles] = useState(vehiclesSeed);
|
|
||||||
const [recentIds, setRecentIds] = useState([1,2,3]);
|
|
||||||
const [openVehicle, setOpenVehicle] = useState(null);
|
|
||||||
|
|
||||||
const recent = useMemo(() => recentIds.map(id => vehicles.find(v=>v.id===id)).filter(Boolean), [recentIds, vehicles]);
|
|
||||||
|
|
||||||
const handleOpenVehicle = (v) => {
|
|
||||||
setOpenVehicle(v);
|
|
||||||
setActive("Vehicles");
|
|
||||||
setRecentIds(prev => [v.id, ...prev.filter(x=>x!==v.id)].slice(0,4));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-start justify-center py-6">
|
|
||||||
<div className="w-[380px] rounded-[32px] shadow-2xl flex flex-col border border-slate-200/70 bg-white/70 backdrop-blur-xl">
|
|
||||||
{/* App header */}
|
|
||||||
<div className="px-5 pt-5 pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-lg font-semibold tracking-tight">MotoVaultPro</div>
|
|
||||||
<div className="text-xs text-slate-500">v0.1</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 px-5 pb-5 space-y-5 overflow-y-auto">
|
|
||||||
<div className="min-h-[560px]">
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{active === "Dashboard" && (
|
|
||||||
<motion.div key="dashboard" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
|
||||||
<DashboardScreen recent={recent} />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
{active === "Vehicles" && (
|
|
||||||
<motion.div key="vehicles" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}} className="space-y-6">
|
|
||||||
{openVehicle ? (
|
|
||||||
<VehicleDetail vehicle={openVehicle} onBack={()=>setOpenVehicle(null)} onLogFuel={()=>setActive("Log Fuel")} />
|
|
||||||
) : (
|
|
||||||
<VehiclesScreen vehicles={vehicles} onOpen={handleOpenVehicle} />
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
{active === "Log Fuel" && (
|
|
||||||
<motion.div key="logfuel" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
|
||||||
<FuelForm units={units} vehicles={vehicles} onSave={() => setActive("Vehicles")} />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
{active === "Settings" && (
|
|
||||||
<motion.div key="settings" initial={{opacity:0, y:8}} animate={{opacity:1, y:0}} exit={{opacity:0, y:-8}}>
|
|
||||||
<SettingsScreen units={units} setUnits={setUnits} />
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<BottomNav active={active} setActive={setActive} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
457
motovaultpro_mobile_v2.jsx
Normal file
457
motovaultpro_mobile_v2.jsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
// App.tsx — Material Design 3 styled prototype for MotoVaultPro (MUI v5)
|
||||||
|
// Install deps (npm):
|
||||||
|
// npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
|
||||||
|
// This file is TypeScript-friendly (tsx) but also works in plain React projects if the tooling supports TS.
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ThemeProvider, createTheme, alpha } from "@mui/material/styles";
|
||||||
|
import CssBaseline from "@mui/material/CssBaseline"; // <-- FIX: import from @mui/material, not styles
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActionArea,
|
||||||
|
Button,
|
||||||
|
Grid,
|
||||||
|
Divider,
|
||||||
|
BottomNavigation,
|
||||||
|
BottomNavigationAction,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
TextField,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
ToggleButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
import HomeRoundedIcon from "@mui/icons-material/HomeRounded";
|
||||||
|
import DirectionsCarRoundedIcon from "@mui/icons-material/DirectionsCarRounded";
|
||||||
|
import LocalGasStationRoundedIcon from "@mui/icons-material/LocalGasStationRounded";
|
||||||
|
import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded";
|
||||||
|
|
||||||
|
// ---- Theme (Material Design 3-inspired) ----
|
||||||
|
const primaryHex = "#7A212A"; // brand color
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: "light",
|
||||||
|
primary: { main: primaryHex },
|
||||||
|
secondary: { main: alpha(primaryHex, 0.8) },
|
||||||
|
background: { default: "#F8F5F3", paper: "#FFFFFF" },
|
||||||
|
},
|
||||||
|
shape: { borderRadius: 16 },
|
||||||
|
typography: {
|
||||||
|
fontFamily:
|
||||||
|
"Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
|
||||||
|
h3: { fontWeight: 700, letterSpacing: -0.5 },
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow:
|
||||||
|
"0 1px 2px rgba(16,24,40,.04), 0 4px 16px rgba(16,24,40,.06)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
variants: [
|
||||||
|
// Custom MD3-like "tonal" button
|
||||||
|
{
|
||||||
|
// TS note: We define a custom variant name, which runtime supports via props match,
|
||||||
|
// but TypeScript doesn't know it by default. We handle that at usage with `as any`.
|
||||||
|
props: { variant: "tonal" as any },
|
||||||
|
style: ({ theme }) => ({
|
||||||
|
backgroundColor: alpha(theme.palette.primary.main, 0.12),
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
borderRadius: 999,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
paddingInline: 18,
|
||||||
|
"&:hover": { backgroundColor: alpha(theme.palette.primary.main, 0.18) },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
props: { variant: "contained" },
|
||||||
|
style: ({ theme }) => ({
|
||||||
|
borderRadius: 999,
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 700,
|
||||||
|
paddingInline: 20,
|
||||||
|
boxShadow: "0 8px 24px " + alpha(theme.palette.primary.main, 0.25),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MuiBottomNavigation: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderTop: "1px solid rgba(16,24,40,.08)",
|
||||||
|
background: alpha("#FFFFFF", 0.8),
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiBottomNavigationAction: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
minWidth: 0,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
"&.Mui-selected": { color: primaryHex },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Types ----
|
||||||
|
export type Vehicle = {
|
||||||
|
id: number;
|
||||||
|
year: number;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const vehiclesSeed: Vehicle[] = [
|
||||||
|
{ id: 1, year: 2021, make: "Chevrolet", model: "Malibu" },
|
||||||
|
{ id: 2, year: 2019, make: "Toyota", model: "Camry" },
|
||||||
|
{ id: 3, year: 2018, make: "Ford", model: "F-150" },
|
||||||
|
{ id: 4, year: 2022, make: "Honda", model: "CR‑V" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- Reusable bits ----
|
||||||
|
const Spark = ({
|
||||||
|
points = [3, 5, 4, 6, 5, 7, 6] as number[],
|
||||||
|
color = primaryHex,
|
||||||
|
}) => {
|
||||||
|
const width = 120;
|
||||||
|
const height = 36;
|
||||||
|
const max = Math.max(...points);
|
||||||
|
const min = Math.min(...points);
|
||||||
|
const path = points
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = (i / (points.length - 1)) * width;
|
||||||
|
const y = height - ((v - min) / (max - min || 1)) * height;
|
||||||
|
return `${i === 0 ? "M" : "L"}${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<Box component="svg" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||||
|
<path d={path} fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VehicleCard = ({
|
||||||
|
v,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
v: Vehicle;
|
||||||
|
onClick?: (v: Vehicle) => void;
|
||||||
|
}) => (
|
||||||
|
<Card sx={{ borderRadius: 18, overflow: "hidden", minWidth: 260 }}>
|
||||||
|
<CardActionArea onClick={() => onClick?.(v)}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box sx={{ height: 120, bgcolor: "#F2EAEA", borderRadius: 3, mb: 2 }} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||||
|
{v.make} {v.model}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{v.year}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Screens ----
|
||||||
|
const Dashboard = ({ recent }: { recent: Vehicle[] }) => (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Recent Vehicles
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, overflowX: "auto", pb: 1, mb: 3 }}>
|
||||||
|
{recent.map((v) => (
|
||||||
|
<VehicleCard key={v.id} v={v} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Fuel Spent This Month
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ mt: 0.5 }}>
|
||||||
|
$134.22
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Spark />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
|
Average Price
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4" sx={{ mt: 0.5 }}>
|
||||||
|
$3.69/gal
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Spark points={[2, 3, 5, 4, 6, 5, 7]} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5 }}>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Vehicles = ({
|
||||||
|
vehicles,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
vehicles: Vehicle[];
|
||||||
|
onOpen: (v: Vehicle) => void;
|
||||||
|
}) => (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||||
|
Vehicles
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{vehicles.map((v) => (
|
||||||
|
<Grid item xs={12} key={v.id}>
|
||||||
|
<VehicleCard v={v} onClick={onOpen} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const VehicleDetail = ({ v, onBack }: { v: Vehicle; onBack: () => void }) => (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Button variant="text" onClick={onBack}>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h4" sx={{ mt: 1, mb: 2 }}>
|
||||||
|
{v.make} {v.model}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
|
Fuel Logs
|
||||||
|
</Typography>
|
||||||
|
<Card sx={{ mb: 1 }}>
|
||||||
|
<CardContent sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<span>Apr 24</span>
|
||||||
|
<span>15,126 mi</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ mb: 1 }}>
|
||||||
|
<CardContent sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<span>Mar 13</span>
|
||||||
|
<span>14,300 mi</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card sx={{ mb: 1 }}>
|
||||||
|
<CardContent sx={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<span>Jan 10</span>
|
||||||
|
<span>14,055 mi</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LogFuel = ({ vehicles }: { vehicles: Vehicle[] }) => {
|
||||||
|
const [vehicleId, setVehicleId] = useState<number>(vehicles[0]?.id || 1);
|
||||||
|
const [date, setDate] = useState<string>(new Date().toISOString().slice(0, 10));
|
||||||
|
const [odo, setOdo] = useState<number>(15126);
|
||||||
|
const [qty, setQty] = useState<number>(12.5);
|
||||||
|
const [price, setPrice] = useState<number>(3.79);
|
||||||
|
const handleSave = () => {
|
||||||
|
alert(`Saved fuel log for vehicle ${vehicleId}`);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||||
|
Log Fuel
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="veh">Vehicle</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="veh"
|
||||||
|
label="Vehicle"
|
||||||
|
value={vehicleId}
|
||||||
|
onChange={(e) => setVehicleId(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{vehicles.map((v) => (
|
||||||
|
<MenuItem key={v.id} value={v.id}>{`${v.year} ${v.make} ${v.model}`}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="date"
|
||||||
|
label="Date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="Odometer (mi)"
|
||||||
|
value={odo}
|
||||||
|
onChange={(e) => setOdo(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="Quantity (gal)"
|
||||||
|
value={qty}
|
||||||
|
onChange={(e) => setQty(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="number"
|
||||||
|
label="Price / gal"
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField fullWidth label="Octane (gasoline)" value={"87"} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Button onClick={handleSave} size="large" variant="contained">
|
||||||
|
Save Fuel Log
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Settings = () => {
|
||||||
|
const [distance, setDistance] = useState("mi");
|
||||||
|
const [fuel, setFuel] = useState("gal");
|
||||||
|
return (
|
||||||
|
<Box sx={{ pb: 10 }}>
|
||||||
|
<Typography variant="h3" sx={{ mb: 2 }}>
|
||||||
|
Settings
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
Distance
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
value={distance}
|
||||||
|
onChange={(_, v) => v && setDistance(v)}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<ToggleButton value="mi">Miles</ToggleButton>
|
||||||
|
<ToggleButton value="km">Kilometers</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
Fuel
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
value={fuel}
|
||||||
|
onChange={(_, v) => v && setFuel(v)}
|
||||||
|
>
|
||||||
|
<ToggleButton value="gal">U.S. Gallons</ToggleButton>
|
||||||
|
<ToggleButton value="L">Liters</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- (Dev) Runtime smoke tests ----
|
||||||
|
const DevTests = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
console.assert(!!ThemeProvider, "ThemeProvider should be available");
|
||||||
|
console.assert(typeof createTheme === "function", "createTheme should be a function");
|
||||||
|
console.assert(!!CssBaseline, "CssBaseline should be importable from @mui/material");
|
||||||
|
console.log("[DevTests] Basic runtime checks passed.");
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Root App with bottom nav ----
|
||||||
|
export default function App() {
|
||||||
|
const [tab, setTab] = useState(0); // 0: Dashboard, 1: Vehicles, 2: Log Fuel, 3: Settings
|
||||||
|
const [open, setOpen] = useState<null | Vehicle>(null);
|
||||||
|
const recent = useMemo(() => vehiclesSeed.slice(0, 2), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<DevTests />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: theme.palette.background.default,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container
|
||||||
|
maxWidth="xs"
|
||||||
|
sx={{ bgcolor: "background.paper", borderRadius: 4, p: 2, boxShadow: 6 }}
|
||||||
|
>
|
||||||
|
{tab === 0 && <Dashboard recent={recent} />}
|
||||||
|
{tab === 1 &&
|
||||||
|
(open ? (
|
||||||
|
<VehicleDetail v={open} onBack={() => setOpen(null)} />
|
||||||
|
) : (
|
||||||
|
<Vehicles vehicles={vehiclesSeed} onOpen={setOpen} />
|
||||||
|
))}
|
||||||
|
{tab === 2 && <LogFuel vehicles={vehiclesSeed} />}
|
||||||
|
{tab === 3 && <Settings />}
|
||||||
|
<BottomNavigation
|
||||||
|
showLabels
|
||||||
|
value={tab}
|
||||||
|
onChange={(_, v) => setTab(v)}
|
||||||
|
sx={{ position: "sticky", bottom: 0, mt: 2 }}
|
||||||
|
>
|
||||||
|
<BottomNavigationAction label="Dashboard" icon={<HomeRoundedIcon />} />
|
||||||
|
<BottomNavigationAction label="Vehicles" icon={<DirectionsCarRoundedIcon />} />
|
||||||
|
<BottomNavigationAction label="Log Fuel" icon={<LocalGasStationRoundedIcon />} />
|
||||||
|
<BottomNavigationAction label="Settings" icon={<SettingsRoundedIcon />} />
|
||||||
|
</BottomNavigation>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user