MVP with new UX

This commit is contained in:
Eric Gullickson
2025-08-09 17:45:54 -05:00
parent 8f5117a4e2
commit d60c3ec00e
18 changed files with 1572 additions and 573 deletions

View File

@@ -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/testing happens in Docker containers
no local package installations:

View File

@@ -24,7 +24,12 @@
"zod": "^3.22.4",
"date-fns": "^3.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": {
"@types/react": "^18.2.42",

View File

@@ -1,40 +1,226 @@
/**
* @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 { 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 { 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 { Vehicle } from './features/vehicles/types/vehicles.types';
function App() {
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 (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 (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Loading...</div>
</div>
</ThemeProvider>
);
}
if (!isAuthenticated) {
if (mobileMode) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<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 (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center max-w-md mx-auto px-6">
<h1 className="text-4xl font-bold text-gray-900 mb-4">MotoVaultPro</h1>
<p className="text-gray-600 mb-8">Your personal vehicle management platform</p>
<Button onClick={() => loginWithRedirect()}>
Login to Continue
</Button>
</div>
<DebugInfo />
</div>
</ThemeProvider>
);
}
// Mobile app rendering
if (mobileMode) {
return (
<Layout>
<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 (
<ThemeProvider theme={md3Theme}>
<CssBaseline />
<Layout mobileMode={false}>
<Routes>
<Route path="/" element={<Navigate to="/vehicles" replace />} />
<Route path="/vehicles" element={<VehiclesPage />} />
@@ -44,7 +230,9 @@ function App() {
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="*" element={<Navigate to="/vehicles" replace />} />
</Routes>
<DebugInfo />
</Layout>
</ThemeProvider>
);
}

View File

@@ -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 { useAuth0 } from '@auth0/auth0-react';
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 { Button } from '../shared-minimal/components/Button';
import { clsx } from 'clsx';
interface LayoutProps {
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 { sidebarOpen, toggleSidebar } = useAppStore();
const location = useLocation();
const theme = useTheme();
const navigation = [
{ name: 'Vehicles', href: '/vehicles', icon: '🚗' },
{ name: 'Fuel Logs', href: '/fuel-logs', icon: '⛽' },
{ name: 'Maintenance', href: '/maintenance', icon: '🔧' },
{ name: 'Gas Stations', href: '/stations', icon: '🏪' },
{ name: 'Vehicles', href: '/vehicles', icon: <DirectionsCarRoundedIcon sx={{ fontSize: 20 }} /> },
{ 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 }} /> },
];
// Mobile layout
if (mobileMode) {
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<div className={clsx(
'fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-200 ease-in-out',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}>
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">MotoVaultPro</h1>
<button
onClick={toggleSidebar}
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
<div className="w-full min-h-screen bg-background-default">
<Container
maxWidth={false}
sx={{
bgcolor: 'background.paper',
borderRadius: 0,
p: 0,
boxShadow: 0,
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: '100% !important'
}}
>
<span className="sr-only">Close sidebar</span>
</button>
{/* 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">
<div className="px-3">
{navigation.map((item) => (
// Desktop layout
return (
<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
key={item.name}
to={item.href}
className={clsx(
'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'
)}
style={{ textDecoration: 'none' }}
>
<span className="mr-3 text-lg">{item.icon}</span>
<Box
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>
))}
</div>
</nav>
);
})}
</Box>
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span className="text-primary-600 font-medium text-sm">
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar
sx={{
width: 32,
height: 32,
bgcolor: 'primary.main',
fontSize: '0.875rem',
fontWeight: 600
}}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</span>
</div>
</div>
<div className="ml-3 flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
</Avatar>
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{user?.name || user?.email}
</p>
</div>
</div>
</Typography>
</Box>
</Box>
<Button
variant="secondary"
size="sm"
className="w-full mt-3"
className="w-full"
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
>
Sign Out
</Button>
</div>
</div>
</Box>
</Paper>
{/* Main content */}
<div className={clsx(
'transition-all duration-200 ease-in-out',
sidebarOpen ? 'ml-64' : 'ml-0'
)}>
{/* Top bar */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="flex items-center justify-between h-16 px-6">
<button
onClick={toggleSidebar}
className="p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100"
<Box
sx={{
ml: sidebarOpen ? '256px' : '0',
transition: 'margin-left 0.2s ease-in-out',
}}
>
<span className="sr-only">Open sidebar</span>
</button>
<div className="text-sm text-gray-500">
{/* Top bar */}
<Paper
elevation={1}
sx={{
borderRadius: 0,
borderBottom: 1,
borderColor: 'divider'
}}
>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: 64,
px: 3
}}>
<IconButton
onClick={toggleSidebar}
sx={{ color: 'text.secondary' }}
>
<MenuIcon />
</IconButton>
<Typography variant="body2" color="text.secondary">
Welcome back, {user?.name || user?.email}
</div>
</div>
</header>
</Typography>
</Box>
</Paper>
{/* Page content */}
<main className="p-6">
<div className="max-w-7xl mx-auto">
<Box component="main" sx={{ p: 3 }}>
<Container maxWidth="xl">
{children}
</div>
</main>
</div>
</Container>
</Box>
</Box>
{/* Backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
<Box
sx={{
position: 'fixed',
inset: 0,
zIndex: 999,
bgcolor: 'rgba(0,0,0,0.5)',
display: { lg: 'none' }
}}
onClick={toggleSidebar}
/>
)}
</div>
</Box>
);
};

View File

@@ -1,11 +1,12 @@
/**
* @ai-summary Vehicle card component
* @ai-summary Vehicle card component with Material Design 3
*/
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 { Card } from '../../../shared-minimal/components/Card';
import { Button } from '../../../shared-minimal/components/Button';
interface VehicleCardProps {
vehicle: Vehicle;
@@ -14,51 +15,96 @@ interface VehicleCardProps {
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> = ({
vehicle,
onEdit,
onDelete,
onSelect,
}) => {
return (
<Card className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => onSelect(vehicle.id)}>
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">
{vehicle.nickname || `${vehicle.year} ${vehicle.make} ${vehicle.model}`}
</h3>
<p className="text-sm text-gray-500 mt-1">VIN: {vehicle.vin}</p>
{vehicle.licensePlate && (
<p className="text-sm text-gray-500">License: {vehicle.licensePlate}</p>
)}
<p className="text-sm text-gray-600 mt-2">
Odometer: {vehicle.odometerReading.toLocaleString()} miles
</p>
</div>
const displayName = vehicle.nickname ||
`${vehicle.year} ${vehicle.make} ${vehicle.model}`;
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
'&:hover': {
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 && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
License: {vehicle.licensePlate}
</Typography>
)}
<Typography variant="body2" color="text.primary" sx={{ mt: 1, fontWeight: 500 }}>
Odometer: {vehicle.odometerReading.toLocaleString()} miles
</Typography>
</CardContent>
</CardActionArea>
<Box sx={{
display: 'flex',
justifyContent: 'flex-end',
gap: 1,
p: 2,
pt: 0
}}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onEdit(vehicle);
}}
sx={{ color: 'text.secondary' }}
>
Edit
</Button>
<Button
size="sm"
variant="danger"
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onDelete(vehicle.id);
}}
sx={{ color: 'error.main' }}
>
Delete
</Button>
</div>
</div>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Card>
);
};

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

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

View File

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

View File

@@ -1,12 +1,13 @@
/**
* @ai-summary Main vehicles page
* @ai-summary Main vehicles page with Material Design 3
*/
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 { VehicleCard } from '../components/VehicleCard';
import { VehicleForm } from '../components/VehicleForm';
import { Button } from '../../../shared-minimal/components/Button';
import { Card } from '../../../shared-minimal/components/Card';
import { useAppStore } from '../../../core/store';
import { useNavigate } from 'react-router-dom';
@@ -33,24 +34,45 @@ export const VehiclesPage: React.FC = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading vehicles...</div>
</div>
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '50vh'
}}>
<Typography color="text.secondary">Loading vehicles...</Typography>
</Box>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">My Vehicles</h1>
<Box sx={{ py: 2 }}>
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 4
}}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
My Vehicles
</Typography>
{!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 && (
<Card>
<h2 className="text-lg font-semibold mb-4">Add New Vehicle</h2>
<Card className="mb-6">
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
Add New Vehicle
</Typography>
<VehicleForm
onSubmit={async (data) => {
await createVehicle.mutateAsync(data);
@@ -64,26 +86,36 @@ export const VehiclesPage: React.FC = () => {
{vehicles?.length === 0 ? (
<Card>
<div className="text-center py-12">
<p className="text-gray-500 mb-4">No vehicles added yet</p>
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="text.secondary" sx={{ mb: 3 }}>
No vehicles added yet
</Typography>
{!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>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Grid container spacing={3}>
{vehicles?.map((vehicle) => (
<Grid item xs={12} md={6} lg={4} key={vehicle.id}>
<VehicleCard
key={vehicle.id}
vehicle={vehicle}
onEdit={(v) => console.log('Edit', v)}
onDelete={handleDelete}
onSelect={handleSelectVehicle}
/>
</Grid>
))}
</div>
</Grid>
)}
</div>
</Box>
);
};

View File

@@ -1,9 +1,9 @@
/**
* @ai-summary Reusable card component
* @ai-summary Reusable card component with Material Design 3
*/
import React from 'react';
import { clsx } from 'clsx';
import { Card as MuiCard, CardContent } from '@mui/material';
interface CardProps {
children: React.ReactNode;
@@ -18,24 +18,27 @@ export const Card: React.FC<CardProps> = ({
padding = 'md',
onClick,
}) => {
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
const paddingStyles = {
none: 0,
sm: 1,
md: 2,
lg: 3,
};
return (
<div
className={clsx(
'bg-white rounded-lg shadow-sm border border-gray-200',
paddings[padding],
onClick && 'cursor-pointer',
className
)}
<MuiCard
onClick={onClick}
sx={{
cursor: onClick ? 'pointer' : 'default',
'&:hover': onClick ? {
boxShadow: 2
} : {}
}}
className={className}
>
<CardContent sx={{ p: paddingStyles[padding] }}>
{children}
</div>
</CardContent>
</MuiCard>
);
};

View File

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

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

View File

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

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

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

View File

@@ -9,13 +9,18 @@ export default {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
500: '#7A212A',
600: '#7A212A',
700: '#9c2a36',
},
gray: {
850: '#18202f',
}
},
'moto-red': '#7A212A',
'moto-red-light': '#9c2a36',
},
backgroundImage: {
'gradient-moto': 'linear-gradient(90deg, #7A212A, #9c2a36)',
},
},
},

View File

@@ -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: "CRV", 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
View 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: "CRV" },
];
// ---- 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>
);
}