458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
// 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>
|
||
);
|
||
}
|