MVP Build

This commit is contained in:
Eric Gullickson
2025-08-09 12:47:15 -05:00
parent 2e8816df7f
commit 8f5117a4e2
92 changed files with 5910 additions and 0 deletions

10
frontend/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Auth0 Configuration
VITE_AUTH0_DOMAIN=your-auth0-domain.us.auth0.com
VITE_AUTH0_CLIENT_ID=your-auth0-client-id
VITE_AUTH0_AUDIENCE=https://your-api-audience
# API Configuration
VITE_API_BASE_URL=http://localhost:3001/api
# Google Maps (for future stations feature)
VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key

39
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules/
/.pnp
.pnp.js
# Production
/build
/dist
# Environment variables
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Testing
/coverage
# Misc
*.tgz
*.tar.gz

45
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Production Dockerfile for MotoVaultPro Frontend
FROM node:20-alpine as build
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install production dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage with nginx
FROM nginx:alpine
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Create non-root user for nginx
RUN addgroup -g 1001 -S nginx && \
adduser -S frontend -u 1001 -G nginx
# Change ownership of nginx directories
RUN chown -R frontend:nginx /var/cache/nginx && \
chown -R frontend:nginx /var/log/nginx && \
chown -R frontend:nginx /etc/nginx/conf.d
RUN touch /var/run/nginx.pid && \
chown -R frontend:nginx /var/run/nginx.pid
USER frontend
# Expose port
EXPOSE 3000
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

24
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,24 @@
# Development Dockerfile for MotoVaultPro Frontend
FROM node:20-alpine
# Set working directory
WORKDIR /app
# Install development tools
RUN apk add --no-cache git
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev dependencies)
RUN npm install
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Run as root for development simplicity
# Note: In production, use proper user management
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

39
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,39 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from '@typescript-eslint/eslint-plugin';
import parser from '@typescript-eslint/parser';
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parser,
},
plugins: {
'@typescript-eslint': tseslint,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
args: 'after-used'
}],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
];

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MotoVaultPro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,39 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend
location /api {
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
}
}

48
frontend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "motovaultpro-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"lint": "eslint src",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"@auth0/auth0-react": "^2.2.3",
"axios": "^1.6.2",
"zustand": "^4.4.6",
"@tanstack/react-query": "^5.8.4",
"react-hook-form": "^7.48.2",
"@hookform/resolvers": "^3.3.2",
"zod": "^3.22.4",
"date-fns": "^3.0.0",
"clsx": "^2.0.0",
"react-hot-toast": "^2.4.1"
},
"devDependencies": {
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.54.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.2",
"vite": "^5.0.6",
"vitest": "^1.0.1",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/user-event": "^14.5.1"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

51
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,51 @@
/**
* @ai-summary Main app component with routing
*/
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { Layout } from './components/Layout';
import { VehiclesPage } from './features/vehicles/pages/VehiclesPage';
import { Button } from './shared-minimal/components/Button';
function App() {
const { isLoading, isAuthenticated, loginWithRedirect } = useAuth0();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
<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>
</div>
);
}
return (
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/vehicles" replace />} />
<Route path="/vehicles" element={<VehiclesPage />} />
<Route path="/vehicles/:id" element={<div>Vehicle Details (TODO)</div>} />
<Route path="/fuel-logs" element={<div>Fuel Logs (TODO)</div>} />
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="*" element={<Navigate to="/vehicles" replace />} />
</Routes>
</Layout>
);
}
export default App;

View File

@@ -0,0 +1,130 @@
/**
* @ai-summary Main layout component with navigation
*/
import React from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { Link, useLocation } from 'react-router-dom';
import { useAppStore } from '../core/store';
import { Button } from '../shared-minimal/components/Button';
import { clsx } from 'clsx';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const { user, logout } = useAuth0();
const { sidebarOpen, toggleSidebar } = useAppStore();
const location = useLocation();
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: '🏪' },
];
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"
>
<span className="sr-only">Close sidebar</span>
</button>
</div>
<nav className="mt-6">
<div className="px-3">
{navigation.map((item) => (
<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'
)}
>
<span className="mr-3 text-lg">{item.icon}</span>
{item.name}
</Link>
))}
</div>
</nav>
<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">
{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">
{user?.name || user?.email}
</p>
</div>
</div>
<Button
variant="secondary"
size="sm"
className="w-full mt-3"
onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}
>
Sign Out
</Button>
</div>
</div>
{/* 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"
>
<span className="sr-only">Open sidebar</span>
</button>
<div className="text-sm text-gray-500">
Welcome back, {user?.name || user?.email}
</div>
</div>
</header>
{/* Page content */}
<main className="p-6">
<div className="max-w-7xl mx-auto">
{children}
</div>
</main>
</div>
{/* Backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={toggleSidebar}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,47 @@
/**
* @ai-summary Axios client configuration for API calls
* @ai-context Handles auth tokens and error responses
*/
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import toast from 'react-hot-toast';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
export const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for auth token
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Token will be added by Auth0 wrapper
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized - Auth0 will redirect to login
toast.error('Session expired. Please login again.');
} else if (error.response?.status === 403) {
toast.error('You do not have permission to perform this action.');
} else if (error.response?.status >= 500) {
toast.error('Server error. Please try again later.');
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@@ -0,0 +1,61 @@
/**
* @ai-summary Auth0 provider wrapper with API token injection
*/
import React from 'react';
import { Auth0Provider as BaseAuth0Provider, useAuth0 } from '@auth0/auth0-react';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../api/client';
interface Auth0ProviderProps {
children: React.ReactNode;
}
export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
const navigate = useNavigate();
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
const onRedirectCallback = (appState?: { returnTo?: string }) => {
navigate(appState?.returnTo || '/dashboard');
};
return (
<BaseAuth0Provider
domain={domain}
clientId={clientId}
authorizationParams={{
redirect_uri: window.location.origin,
audience: audience,
}}
onRedirectCallback={onRedirectCallback}
cacheLocation="localstorage"
>
<TokenInjector>{children}</TokenInjector>
</BaseAuth0Provider>
);
};
// Component to inject token into API client
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessTokenSilently, isAuthenticated } = useAuth0();
React.useEffect(() => {
if (isAuthenticated) {
// Add token to all API requests
apiClient.interceptors.request.use(async (config) => {
try {
const token = await getAccessTokenSilently();
config.headers.Authorization = `Bearer ${token}`;
} catch (error) {
console.error('Failed to get access token:', error);
}
return config;
});
}
}, [isAuthenticated, getAccessTokenSilently]);
return <>{children}</>;
};

View File

@@ -0,0 +1,54 @@
/**
* @ai-summary Global state management with Zustand
* @ai-context Minimal global state, features manage their own state
*/
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name?: string;
}
interface AppState {
// User state
user: User | null;
setUser: (user: User | null) => void;
// UI state
sidebarOpen: boolean;
toggleSidebar: () => void;
// Selected vehicle (for context)
selectedVehicleId: string | null;
setSelectedVehicle: (id: string | null) => void;
}
export const useAppStore = create<AppState>()(
devtools(
persist(
(set) => ({
// User state
user: null,
setUser: (user) => set({ user }),
// UI state
sidebarOpen: true,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
// Selected vehicle
selectedVehicleId: null,
setSelectedVehicle: (vehicleId) => set({ selectedVehicleId: vehicleId }),
}),
{
name: 'motovaultpro-storage',
partialize: (state) => ({
selectedVehicleId: state.selectedVehicleId,
sidebarOpen: state.sidebarOpen,
}),
}
)
)
);

View File

@@ -0,0 +1,32 @@
/**
* @ai-summary API calls for vehicles feature
*/
import { apiClient } from '../../../core/api/client';
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types';
export const vehiclesApi = {
getAll: async (): Promise<Vehicle[]> => {
const response = await apiClient.get('/vehicles');
return response.data;
},
getById: async (id: string): Promise<Vehicle> => {
const response = await apiClient.get(`/vehicles/${id}`);
return response.data;
},
create: async (data: CreateVehicleRequest): Promise<Vehicle> => {
const response = await apiClient.post('/vehicles', data);
return response.data;
},
update: async (id: string, data: UpdateVehicleRequest): Promise<Vehicle> => {
const response = await apiClient.put(`/vehicles/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/vehicles/${id}`);
},
};

View File

@@ -0,0 +1,64 @@
/**
* @ai-summary Vehicle card component
*/
import React from 'react';
import { Vehicle } from '../types/vehicles.types';
import { Card } from '../../../shared-minimal/components/Card';
import { Button } from '../../../shared-minimal/components/Button';
interface VehicleCardProps {
vehicle: Vehicle;
onEdit: (vehicle: Vehicle) => void;
onDelete: (id: string) => void;
onSelect: (id: string) => void;
}
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>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={(e) => {
e.stopPropagation();
onEdit(vehicle);
}}
>
Edit
</Button>
<Button
size="sm"
variant="danger"
onClick={(e) => {
e.stopPropagation();
onDelete(vehicle.id);
}}
>
Delete
</Button>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,115 @@
/**
* @ai-summary Vehicle form component for create/edit
*/
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '../../../shared-minimal/components/Button';
import { CreateVehicleRequest } from '../types/vehicles.types';
const vehicleSchema = z.object({
vin: z.string().length(17, 'VIN must be exactly 17 characters'),
nickname: z.string().optional(),
color: z.string().optional(),
licensePlate: z.string().optional(),
odometerReading: z.number().min(0).optional(),
});
interface VehicleFormProps {
onSubmit: (data: CreateVehicleRequest) => void;
onCancel: () => void;
initialData?: Partial<CreateVehicleRequest>;
loading?: boolean;
}
export const VehicleForm: React.FC<VehicleFormProps> = ({
onSubmit,
onCancel,
initialData,
loading,
}) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateVehicleRequest>({
resolver: zodResolver(vehicleSchema),
defaultValues: initialData,
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
VIN <span className="text-red-500">*</span>
</label>
<input
{...register('vin')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Enter 17-character VIN"
/>
{errors.vin && (
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nickname
</label>
<input
{...register('nickname')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="e.g., Family Car"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Color
</label>
<input
{...register('color')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="e.g., Blue"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
License Plate
</label>
<input
{...register('licensePlate')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="e.g., ABC-123"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Current Odometer Reading
</label>
<input
{...register('odometerReading', { valueAsNumber: true })}
type="number"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="e.g., 50000"
/>
</div>
<div className="flex gap-3 justify-end pt-4">
<Button variant="secondary" onClick={onCancel} type="button">
Cancel
</Button>
<Button type="submit" loading={loading}>
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,78 @@
/**
* @ai-summary React hooks for vehicles feature
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { vehiclesApi } from '../api/vehicles.api';
import { CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types';
import toast from 'react-hot-toast';
interface ApiError {
response?: {
data?: {
error?: string;
};
};
message?: string;
}
export const useVehicles = () => {
return useQuery({
queryKey: ['vehicles'],
queryFn: vehiclesApi.getAll,
});
};
export const useVehicle = (id: string) => {
return useQuery({
queryKey: ['vehicles', id],
queryFn: () => vehiclesApi.getById(id),
enabled: !!id,
});
};
export const useCreateVehicle = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateVehicleRequest) => vehiclesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle added successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to add vehicle');
},
});
};
export const useUpdateVehicle = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateVehicleRequest }) =>
vehiclesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle updated successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to update vehicle');
},
});
};
export const useDeleteVehicle = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => vehiclesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
toast.success('Vehicle deleted successfully');
},
onError: (error: ApiError) => {
toast.error(error.response?.data?.error || 'Failed to delete vehicle');
},
});
};

View File

@@ -0,0 +1,89 @@
/**
* @ai-summary Main vehicles page
*/
import React, { useState } from 'react';
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';
export const VehiclesPage: React.FC = () => {
const navigate = useNavigate();
const { data: vehicles, isLoading } = useVehicles();
const createVehicle = useCreateVehicle();
const deleteVehicle = useDeleteVehicle();
const setSelectedVehicle = useAppStore(state => state.setSelectedVehicle);
const [showForm, setShowForm] = useState(false);
const handleSelectVehicle = (id: string) => {
setSelectedVehicle(id);
navigate(`/vehicles/${id}`);
};
const handleDelete = async (id: string) => {
if (confirm('Are you sure you want to delete this vehicle?')) {
await deleteVehicle.mutateAsync(id);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading vehicles...</div>
</div>
);
}
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>
{!showForm && (
<Button onClick={() => setShowForm(true)}>Add Vehicle</Button>
)}
</div>
{showForm && (
<Card>
<h2 className="text-lg font-semibold mb-4">Add New Vehicle</h2>
<VehicleForm
onSubmit={async (data) => {
await createVehicle.mutateAsync(data);
setShowForm(false);
}}
onCancel={() => setShowForm(false)}
loading={createVehicle.isPending}
/>
</Card>
)}
{vehicles?.length === 0 ? (
<Card>
<div className="text-center py-12">
<p className="text-gray-500 mb-4">No vehicles added yet</p>
{!showForm && (
<Button onClick={() => setShowForm(true)}>Add Your First Vehicle</Button>
)}
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{vehicles?.map((vehicle) => (
<VehicleCard
key={vehicle.id}
vehicle={vehicle}
onEdit={(v) => console.log('Edit', v)}
onDelete={handleDelete}
onSelect={handleSelectVehicle}
/>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,34 @@
/**
* @ai-summary Type definitions for vehicles feature
*/
export interface Vehicle {
id: string;
userId: string;
vin: string;
make?: string;
model?: string;
year?: number;
nickname?: string;
color?: string;
licensePlate?: string;
odometerReading: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateVehicleRequest {
vin: string;
nickname?: string;
color?: string;
licensePlate?: string;
odometerReading?: number;
}
export interface UpdateVehicleRequest {
nickname?: string;
color?: string;
licensePlate?: string;
odometerReading?: number;
}

16
frontend/src/index.css Normal file
View File

@@ -0,0 +1,16 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}

43
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,43 @@
/**
* @ai-summary Application entry point
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { Auth0Provider } from './core/auth/Auth0Provider';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<Auth0Provider>
<QueryClientProvider client={queryClient}>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
}}
/>
</QueryClientProvider>
</Auth0Provider>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,62 @@
/**
* @ai-summary Reusable button component
*/
import React from 'react';
import { clsx } from 'clsx';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
loading = false,
disabled,
children,
className,
...props
}) => {
const baseStyles = 'font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
const variants = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
className={clsx(
baseStyles,
variants[variant],
sizes[size],
(disabled || loading) && 'opacity-50 cursor-not-allowed',
className
)}
disabled={disabled || loading}
{...props}
>
{loading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</span>
) : (
children
)}
</button>
);
};

View File

@@ -0,0 +1,41 @@
/**
* @ai-summary Reusable card component
*/
import React from 'react';
import { clsx } from 'clsx';
interface CardProps {
children: React.ReactNode;
className?: string;
padding?: 'none' | 'sm' | 'md' | 'lg';
onClick?: () => void;
}
export const Card: React.FC<CardProps> = ({
children,
className,
padding = 'md',
onClick,
}) => {
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
return (
<div
className={clsx(
'bg-white rounded-lg shadow-sm border border-gray-200',
paddings[padding],
onClick && 'cursor-pointer',
className
)}
onClick={onClick}
>
{children}
</div>
);
};

12
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_AUTH0_DOMAIN: string
readonly VITE_AUTH0_CLIENT_ID: string
readonly VITE_AUTH0_AUDIENCE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
gray: {
850: '#18202f',
}
},
},
},
plugins: [],
}

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@/features/*": ["src/features/*"],
"@/core/*": ["src/core/*"],
"@/shared/*": ["src/shared-minimal/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
host: '0.0.0.0', // Allow external connections for container
},
});