MVP Build
This commit is contained in:
10
frontend/.env.example
Normal file
10
frontend/.env.example
Normal 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
39
frontend/.gitignore
vendored
Normal 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
45
frontend/Dockerfile
Normal 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
24
frontend/Dockerfile.dev
Normal 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
39
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
39
frontend/nginx.conf
Normal 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
48
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
51
frontend/src/App.tsx
Normal file
51
frontend/src/App.tsx
Normal 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;
|
||||
130
frontend/src/components/Layout.tsx
Normal file
130
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
frontend/src/core/api/client.ts
Normal file
47
frontend/src/core/api/client.ts
Normal 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;
|
||||
61
frontend/src/core/auth/Auth0Provider.tsx
Normal file
61
frontend/src/core/auth/Auth0Provider.tsx
Normal 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}</>;
|
||||
};
|
||||
54
frontend/src/core/store/index.ts
Normal file
54
frontend/src/core/store/index.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
32
frontend/src/features/vehicles/api/vehicles.api.ts
Normal file
32
frontend/src/features/vehicles/api/vehicles.api.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
64
frontend/src/features/vehicles/components/VehicleCard.tsx
Normal file
64
frontend/src/features/vehicles/components/VehicleCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
frontend/src/features/vehicles/components/VehicleForm.tsx
Normal file
115
frontend/src/features/vehicles/components/VehicleForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
78
frontend/src/features/vehicles/hooks/useVehicles.ts
Normal file
78
frontend/src/features/vehicles/hooks/useVehicles.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
};
|
||||
89
frontend/src/features/vehicles/pages/VehiclesPage.tsx
Normal file
89
frontend/src/features/vehicles/pages/VehiclesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
frontend/src/features/vehicles/types/vehicles.types.ts
Normal file
34
frontend/src/features/vehicles/types/vehicles.types.ts
Normal 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
16
frontend/src/index.css
Normal 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
43
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
62
frontend/src/shared-minimal/components/Button.tsx
Normal file
62
frontend/src/shared-minimal/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
frontend/src/shared-minimal/components/Card.tsx
Normal file
41
frontend/src/shared-minimal/components/Card.tsx
Normal 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
12
frontend/src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal 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
31
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user