feat: navigation and UX improvements complete

This commit is contained in:
Eric Gullickson
2025-12-26 09:25:42 -06:00
parent 50baec390f
commit 8c13dc0a55
23 changed files with 327 additions and 126 deletions

View File

@@ -11,8 +11,8 @@ export class UserPreferencesRepository {
async findByUserId(userId: string): Promise<UserPreferences | null> {
const query = `
SELECT id, user_id, unit_system, currency_code, time_zone, created_at, updated_at
FROM user_preferences
SELECT id, user_id, unit_system, currency_code, time_zone, dark_mode, created_at, updated_at
FROM user_preferences
WHERE user_id = $1
`;
@@ -22,8 +22,8 @@ export class UserPreferencesRepository {
async create(data: CreateUserPreferencesRequest): Promise<UserPreferences> {
const query = `
INSERT INTO user_preferences (user_id, unit_system, currency_code, time_zone)
VALUES ($1, $2, $3, $4)
INSERT INTO user_preferences (user_id, unit_system, currency_code, time_zone, dark_mode)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`;
@@ -31,7 +31,8 @@ export class UserPreferencesRepository {
data.userId,
data.unitSystem || 'imperial',
data.currencyCode || 'USD',
data.timeZone || 'UTC'
data.timeZone || 'UTC',
data.darkMode ?? null
];
const result = await this.db.query(query, values);
@@ -55,6 +56,10 @@ export class UserPreferencesRepository {
fields.push(`time_zone = $${paramCount++}`);
values.push(data.timeZone);
}
if (data.darkMode !== undefined) {
fields.push(`dark_mode = $${paramCount++}`);
values.push(data.darkMode);
}
if (fields.length === 0) {
return this.findByUserId(userId);
@@ -90,6 +95,7 @@ export class UserPreferencesRepository {
unitSystem: row.unit_system,
currencyCode: row.currency_code || 'USD',
timeZone: row.time_zone || 'UTC',
darkMode: row.dark_mode,
createdAt: row.created_at,
updatedAt: row.updated_at,
};

View File

@@ -0,0 +1,8 @@
-- Migration: Add dark_mode column to user_preferences
-- NULL = use system preference, TRUE = dark mode, FALSE = light mode
ALTER TABLE user_preferences
ADD COLUMN IF NOT EXISTS dark_mode BOOLEAN DEFAULT NULL;
-- Add comment for documentation
COMMENT ON COLUMN user_preferences.dark_mode IS 'User dark mode preference. NULL means use system preference.';

View File

@@ -11,6 +11,7 @@ export interface UserPreferences {
unitSystem: UnitSystem;
currencyCode: string;
timeZone: string;
darkMode: boolean | null;
createdAt: Date;
updatedAt: Date;
}
@@ -20,12 +21,14 @@ export interface CreateUserPreferencesRequest {
unitSystem?: UnitSystem;
currencyCode?: string;
timeZone?: string;
darkMode?: boolean | null;
}
export interface UpdateUserPreferencesRequest {
unitSystem?: UnitSystem;
currencyCode?: string;
timeZone?: string;
darkMode?: boolean | null;
}
export interface UserPreferencesResponse {
@@ -34,6 +37,7 @@ export interface UserPreferencesResponse {
unitSystem: UnitSystem;
currencyCode: string;
timeZone: string;
darkMode: boolean | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -37,6 +37,7 @@ export class UserPreferencesController {
unitSystem: preferences.unitSystem,
currencyCode: preferences.currencyCode,
timeZone: preferences.timeZone,
darkMode: preferences.darkMode,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
});
@@ -55,7 +56,7 @@ export class UserPreferencesController {
) {
try {
const userId = (request as any).user.sub;
const { unitSystem, currencyCode, timeZone } = request.body;
const { unitSystem, currencyCode, timeZone, darkMode } = request.body;
// Validate unitSystem if provided
if (unitSystem && !['imperial', 'metric'].includes(unitSystem)) {
@@ -73,6 +74,14 @@ export class UserPreferencesController {
});
}
// Validate darkMode if provided (must be boolean or null)
if (darkMode !== undefined && darkMode !== null && typeof darkMode !== 'boolean') {
return reply.code(400).send({
error: 'Bad Request',
message: 'darkMode must be a boolean or null',
});
}
// Check if preferences exist, create if not
let preferences = await this.repository.findByUserId(userId);
if (!preferences) {
@@ -81,12 +90,14 @@ export class UserPreferencesController {
unitSystem: unitSystem || 'imperial',
currencyCode: currencyCode || 'USD',
timeZone: timeZone || 'UTC',
darkMode: darkMode,
});
} else {
const updated = await this.repository.update(userId, {
unitSystem,
currencyCode,
timeZone,
darkMode,
});
if (updated) {
preferences = updated;
@@ -99,6 +110,7 @@ export class UserPreferencesController {
unitSystem: preferences.unitSystem,
currencyCode: preferences.currencyCode,
timeZone: preferences.timeZone,
darkMode: preferences.darkMode,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
});

View File

@@ -22,18 +22,15 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Make no assumptions.
- Ask clarifying questions.
- Ultrathink
- You will be extending the "Documents" feature to include manuals.
- You will be auditing the Dark vs Light theme implementation
*** CONTEXT ***
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- You need to extend the Documents feature to include a third "Document Type"
- Right now the document has two types. Insurance and Registration
- The third type will be called "Manual"
- This document will just have the uploaded file and a notes field and Title field
- When implementing this we need to play for the future feature of scanning the document for maintenance schedules
- Add a toggle for this scanning. Label it "Scan for Maintenance Schedule"
- Do not implement this feature at this time but put the toggle in the interface and the backend changes to facility this workflow.
- You need to audit the Dark vs Light theme.
- The colors were not all changed so some of the dark theme
- The dark versus light theme does not save between logins.
- Think hard about the color choices and if any better colors are available form the MVP-COLOR-SCHEME.md
*** CHANGES TO IMPLEMENT ***
- Research this code base and ask iterative questions to compile a complete plan.
@@ -58,30 +55,7 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Debug what could be causing this issue. No changes were made to the Gitlab server besides adding the RESEND variable so there shouldn't be anything on the server causing this issue.
$ chmod +x scripts/inject-secrets.sh
$ ./scripts/inject-secrets.sh
Injecting secrets...
Deploy path: /opt/gitlab-runner/builds/motovaultpro
Secrets dir: /opt/gitlab-runner/builds/motovaultpro/secrets/app
Cleaning up any corrupted secret paths...
ERROR: Variable POSTGRES_PASSWORD is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: Variable AUTH0_CLIENT_SECRET is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: Variable AUTH0_MANAGEMENT_CLIENT_ID is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: Variable AUTH0_MANAGEMENT_CLIENT_SECRET is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: Variable GOOGLE_MAPS_API_KEY is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: Variable GOOGLE_MAPS_MAP_ID is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: Variable CF_DNS_API_TOKEN is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: Variable RESEND_API_KEY is not set
Ensure it exists in GitLab CI/CD Variables
ERROR: One or more secrets failed to inject
Ensure all required CI/CD variables are configured as File type in GitLab
Running after_script
00:00
Running after script...
@@ -89,6 +63,35 @@ Running after script...
*** ROLE ***
- You are a senior DBA with expert knowledge in Postgres SQL.
*** ACTION ***
- Make no assumptions.
- Ask clarifying questions.
- Ultrathink
- You will be implementing an ETL process that takes a export of the NHTSA vPIC database in Postgres and transforming it for use in this application.
*** CONTEXT ***
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- There is an existing database import process in this directory. This process works and should not be changed.
- The source database from the NHTSA vPIC dataset is located in the @vpic-source directory
- Deep research needs to be conducted on how to execute this ETL process.
- The source database is designed for VIN decoding only.
- Example VIN: 2025 Honda Civic Hybrid - 2HGFE4F88SH315466
- Example VIN: 2023 GMC Sierra 1500 AT4x - 3GTUUFEL6PG140748
- Example VIN: 2017 Chevrolet Corvette Z06 - 1G1YU3D64H5602799
*** CHANGES TO IMPLEMENT ***
- Research this code base and ask iterative questions to compile a complete plan.
- generate a project plan
- break into bite-sized tasks and milestones
*** ROLE ***
- You are a senior DBA with expert knowledge in Postgres SQL.

View File

@@ -17,6 +17,7 @@ import CloseIcon from '@mui/icons-material/Close';
import { useAppStore } from '../core/store';
import { Button } from '../shared-minimal/components/Button';
import { NotificationBell } from '../features/notifications';
import { useThemeSync } from '../shared-minimal/theme/useThemeSync';
interface LayoutProps {
children: React.ReactNode;
@@ -28,6 +29,9 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore();
const location = useLocation();
// Sync theme preference with backend
useThemeSync();
// Ensure desktop has a visible navigation by default (only on mount)
React.useEffect(() => {
if (!mobileMode && !sidebarOpen) {
@@ -72,7 +76,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
/>
<div className="flex items-center gap-2">
<NotificationBell />
<div className="text-xs text-slate-500">v1.0</div>
<div className="text-xs text-slate-500 dark:text-titanio">v1.0</div>
</div>
</div>
</div>
@@ -157,7 +161,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
? 'primary.main'
: 'transparent',
color: isActive
? '#FFFFFF'
? 'primary.contrastText'
: 'text.primary',
'&:hover': {
backgroundColor: isActive

View File

@@ -49,38 +49,38 @@ export const SignupForm: React.FC<SignupFormProps> = ({ onSubmit, loading }) =>
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address <span className="text-red-500">*</span>
<label className="block text-sm font-medium text-avus mb-1">
Email Address <span className="text-red-400">*</span>
</label>
<input
{...register('email')}
type="email"
inputMode="email"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-scuro text-avus border-silverstone placeholder-canna focus:outline-none focus:ring-2 focus:ring-abudhabi focus:border-abudhabi"
placeholder="your.email@example.com"
style={{ fontSize: '16px' }}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
<p className="mt-1 text-sm text-red-400">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password <span className="text-red-500">*</span>
<label className="block text-sm font-medium text-avus mb-1">
Password <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
{...register('password')}
type={showPassword ? 'text' : 'password'}
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
className="w-full px-3 py-2 pr-10 border rounded-md min-h-[44px] bg-scuro text-avus border-silverstone placeholder-canna focus:outline-none focus:ring-2 focus:ring-abudhabi focus:border-abudhabi"
placeholder="At least 8 characters"
style={{ fontSize: '16px' }}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
className="absolute right-3 top-1/2 -translate-y-1/2 text-titanio hover:text-avus focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
@@ -96,29 +96,29 @@ export const SignupForm: React.FC<SignupFormProps> = ({ onSubmit, loading }) =>
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
<p className="mt-1 text-sm text-red-400">{errors.password.message}</p>
)}
<p className="mt-1 text-xs text-gray-600">
<p className="mt-1 text-xs text-titanio">
Must be at least 8 characters with one uppercase letter and one number
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm Password <span className="text-red-500">*</span>
<label className="block text-sm font-medium text-avus mb-1">
Confirm Password <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
{...register('confirmPassword')}
type={showConfirmPassword ? 'text' : 'password'}
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
className="w-full px-3 py-2 pr-10 border rounded-md min-h-[44px] bg-scuro text-avus border-silverstone placeholder-canna focus:outline-none focus:ring-2 focus:ring-abudhabi focus:border-abudhabi"
placeholder="Re-enter your password"
style={{ fontSize: '16px' }}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
className="absolute right-3 top-1/2 -translate-y-1/2 text-titanio hover:text-avus focus:outline-none min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
>
{showConfirmPassword ? (
@@ -134,7 +134,7 @@ export const SignupForm: React.FC<SignupFormProps> = ({ onSubmit, loading }) =>
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
<p className="mt-1 text-sm text-red-400">{errors.confirmPassword.message}</p>
)}
</div>

View File

@@ -25,24 +25,28 @@ export const SignupPage: React.FC = () => {
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-rose-50 flex items-center justify-center p-4">
<div className="min-h-screen bg-nero flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg shadow-lg p-8">
<div className="bg-nero border border-white/10 rounded-lg shadow-lg shadow-black/30 p-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-600 mb-2">MotoVaultPro</h1>
<h2 className="text-xl font-semibold text-gray-800">Create Your Account</h2>
<p className="text-sm text-gray-600 mt-2">
<img
src="/images/logos/motovaultpro-title-slogan.png"
alt="MotoVaultPro - Precision Vehicle Management"
className="h-12 md:h-14 w-auto mx-auto mb-4"
/>
<h2 className="text-xl font-semibold text-avus">Create Your Account</h2>
<p className="text-sm text-titanio mt-2">
Start tracking your vehicle maintenance and fuel logs
</p>
</div>
<SignupForm onSubmit={handleSubmit} loading={isPending} />
<div className="mt-6 text-center text-sm text-gray-600">
<div className="mt-6 text-center text-sm text-titanio">
Already have an account?{' '}
<button
onClick={() => navigate('/login')}
className="text-primary-600 hover:text-primary-700 font-medium focus:outline-none focus:underline"
className="text-primary-500 hover:text-primary-400 font-medium focus:outline-none focus:underline"
>
Login
</button>

View File

@@ -158,7 +158,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
<form onSubmit={handleSubmit} className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Vehicle</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Vehicle</label>
<select
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={vehicleID}
@@ -173,7 +173,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Document Type</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Document Type</label>
<select
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={documentType}
@@ -186,7 +186,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Title</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Title</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
@@ -204,7 +204,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
{documentType === 'insurance' && (
<>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Insurance company</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Insurance company</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
@@ -213,7 +213,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Policy number</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Policy number</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
@@ -260,7 +260,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Bodily Injury (Person)</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Bodily Injury (Person)</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
@@ -270,7 +270,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Bodily Injury (Incident)</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Bodily Injury (Incident)</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
@@ -281,7 +281,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Property Damage</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Property Damage</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
@@ -291,7 +291,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Premium</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Premium</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="number"
@@ -307,7 +307,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
{documentType === 'registration' && (
<>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">License Plate</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">License Plate</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="text"
@@ -334,7 +334,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Cost</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Cost</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
type="number"
@@ -356,16 +356,16 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
onChange={(e) => setScanForMaintenance(e.target.checked)}
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500 dark:border-silverstone dark:focus:ring-abudhabi"
/>
<span className="ml-2 text-sm font-medium text-slate-700">
<span className="ml-2 text-sm font-medium text-slate-700 dark:text-avus">
Scan for Maintenance Schedule
</span>
</label>
<span className="ml-2 text-xs text-slate-500">(Coming soon)</span>
<span className="ml-2 text-xs text-slate-500 dark:text-titanio">(Coming soon)</span>
</div>
)}
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Notes</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Notes</label>
<textarea
className="min-h-[88px] rounded-lg border px-3 py-2 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
value={notes}
@@ -374,7 +374,7 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Upload image/PDF</label>
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Upload image/PDF</label>
<input
className="h-11 min-h-[44px] rounded-lg border px-3 py-2 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-500/10 file:text-primary-600 dark:file:bg-abudhabi/20 dark:file:text-abudhabi"
type="file"
@@ -382,13 +382,13 @@ export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="text-sm text-slate-600 mt-1">Uploading... {uploadProgress}%</div>
<div className="text-sm text-slate-600 dark:text-titanio mt-1">Uploading... {uploadProgress}%</div>
)}
</div>
</div>
{error && (
<div className="text-red-600 text-sm mt-3">{error}</div>
<div className="text-red-600 dark:text-red-400 text-sm mt-3">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-2 mt-4">

View File

@@ -187,12 +187,13 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
fullWidth
InputProps={{
readOnly: true,
sx: {
backgroundColor: 'grey.50',
sx: (theme) => ({
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.50',
'& .MuiOutlinedInput-input': {
cursor: 'default',
color: theme.palette.mode === 'dark' ? '#F2F3F6' : 'inherit',
},
},
}),
}}
helperText="Calculated from distance ÷ fuel amount"
sx={{

View File

@@ -330,10 +330,10 @@ export const StationPicker: React.FC<StationPickerProps> = ({
}}
/>
)}
sx={{
sx={(theme) => ({
'& .MuiAutocomplete-groupLabel': {
fontWeight: 600,
backgroundColor: 'grey.100',
backgroundColor: theme.palette.mode === 'dark' ? '#4C4E4D' : 'grey.100',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.5px'
@@ -342,7 +342,7 @@ export const StationPicker: React.FC<StationPickerProps> = ({
minHeight: '44px', // Mobile touch target
padding: '8px 16px'
}
}}
})}
/>
);
};

View File

@@ -11,6 +11,7 @@ export interface UserPreferences {
unitSystem: UnitSystem;
currencyCode: string;
timeZone: string;
darkMode: boolean | null;
createdAt: string;
updatedAt: string;
}
@@ -19,4 +20,5 @@ export interface UpdatePreferencesRequest {
unitSystem?: UnitSystem;
currencyCode?: string;
timeZone?: string;
darkMode?: boolean | null;
}

View File

@@ -396,7 +396,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-2">
Vehicle Photo
</label>
<VehicleImageUpload
@@ -408,10 +408,10 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
VIN or License Plate <span className="text-red-500">*</span>
</label>
<p className="text-xs text-gray-600 mb-2">
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
Enter vehicle VIN (optional)
</p>
<input
@@ -421,14 +421,14 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
style={{ fontSize: '16px' }}
/>
{errors.vin && (
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.vin.message}</p>
)}
</div>
{/* Vehicle Specification Dropdowns */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Year
</label>
<select
@@ -451,7 +451,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Make
</label>
<select
@@ -483,7 +483,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Model
</label>
<select
@@ -518,7 +518,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{/* Trim (left) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Trim
</label>
<select
@@ -551,7 +551,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{/* Engine (middle) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Engine
</label>
<select
@@ -579,7 +579,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{/* Transmission (right) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Transmission
</label>
<select
@@ -607,7 +607,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Nickname
</label>
<input
@@ -620,7 +620,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Color
</label>
<input
@@ -632,7 +632,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
License Plate
</label>
<input
@@ -642,13 +642,13 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
style={{ fontSize: '16px' }}
/>
{errors.licensePlate && (
<p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.licensePlate.message}</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
Current Odometer Reading
</label>
<input

View File

@@ -29,12 +29,12 @@ const DetailField: React.FC<{
className?: string;
}> = ({ label, value, isRequired, className = "" }) => (
<div className={`space-y-1 ${className}`}>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-gray-700 dark:text-avus">
{label} {isRequired && <span className="text-red-500">*</span>}
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
<span className="text-gray-900">
{value || <span className="text-gray-400 italic">Not provided</span>}
<div className="px-3 py-2 bg-gray-50 dark:bg-scuro border border-gray-200 dark:border-silverstone rounded-md">
<span className="text-gray-900 dark:text-avus">
{value || <span className="text-gray-400 dark:text-titanio italic">Not provided</span>}
</span>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useNavigate } from 'react-router-dom';
import { HeroCarousel } from './HomePage/HeroCarousel';
@@ -8,8 +8,18 @@ import { motion } from 'framer-motion';
export const HomePage = () => {
const { loginWithRedirect, isAuthenticated } = useAuth0();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 100);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleAuthAction = () => {
if (isAuthenticated) {
navigate('/garage');
@@ -26,8 +36,8 @@ export const HomePage = () => {
return (
<div className="min-h-screen bg-nero text-avus">
{/* Navigation Bar */}
<nav className="sticky top-0 z-50 bg-nero/90 backdrop-blur border-b border-white/10">
<div className="max-w-7xl mx-auto px-4 md:px-8">
<nav className={`fixed top-0 left-0 right-0 z-50 transition-colors duration-300 ${isScrolled ? 'bg-nero/95 backdrop-blur-sm' : 'bg-transparent'}`}>
<div className="w-full px-4 md:px-8 lg:px-12">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<div className="flex-shrink-0">
@@ -99,7 +109,7 @@ export const HomePage = () => {
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="md:hidden py-4 space-y-3 bg-nero border-t border-white/10"
className="md:hidden py-4 space-y-3 bg-nero/95 backdrop-blur-sm border-t border-white/10"
>
<a
href="#home"
@@ -138,7 +148,7 @@ export const HomePage = () => {
{/* Hero Carousel */}
<section id="home">
<HeroCarousel />
<HeroCarousel onGetStarted={handleAuthAction} />
</section>
{/* Welcome Section */}

View File

@@ -1,4 +1,4 @@
import { useRef } from 'react';
import React, { useRef } from 'react';
import Slider from 'react-slick';
import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';
@@ -22,8 +22,8 @@ const heroSlides: HeroSlide[] = [
},
{
id: 3,
imageSrc: 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=1920&h=1080&fit=crop',
imageAlt: 'Green Performance Car',
imageSrc: 'https://images.unsplash.com/photo-1609138315745-4e44ac3bbd8d?w=1920&h=1080&fit=crop&crop=focalpoint&fp-x=0.5&fp-y=0.6&q=80&auto=format&ixlib=rb-4.1.0',
imageAlt: 'Ferrari SF90',
},
{
id: 4,
@@ -32,8 +32,8 @@ const heroSlides: HeroSlide[] = [
},
{
id: 5,
imageSrc: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?w=1920&h=1080&fit=crop',
imageAlt: 'SUV on Road',
imageSrc: 'https://images.unsplash.com/photo-1740095960937-c7b03f511da4?w=1920&h=1080&fit=crop&crop=focalpoint&fp-x=0.5&fp-y=0.6&q=80&auto=format&ixlib=rb-4.1.0',
imageAlt: 'Corvette Z06 - Black Rose',
},
{
id: 6,
@@ -42,7 +42,11 @@ const heroSlides: HeroSlide[] = [
},
];
export const HeroCarousel = () => {
interface HeroCarouselProps {
onGetStarted: () => void;
}
export const HeroCarousel: React.FC<HeroCarouselProps> = ({ onGetStarted }) => {
const sliderRef = useRef<Slider>(null);
const settings = {
@@ -76,6 +80,19 @@ export const HeroCarousel = () => {
))}
</Slider>
{/* Hero Text Overlay - positioned outside Slider to prevent fading */}
<div className="absolute inset-0 flex flex-col items-center justify-center text-center px-4 pointer-events-none">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-6 md:mb-8 drop-shadow-lg">
Track every mile. Own every detail.
</h1>
<button
onClick={onGetStarted}
className="pointer-events-auto bg-primary-500 hover:bg-primary-600 text-white font-semibold py-3 px-8 md:py-4 md:px-10 rounded-lg text-lg md:text-xl transition-colors duration-300 shadow-lg shadow-black/30 focus:outline-none focus:ring-2 focus:ring-primary-500/50"
>
Get Started
</button>
</div>
<style>{`
.hero-carousel .slick-dots {
bottom: 25px;

View File

@@ -1,10 +1,10 @@
/**
* @ai-summary Theme context for managing light/dark mode across the app
* Uses same localStorage key as useSettings for consistency
* Supports: system preference detection, localStorage persistence, backend sync
* Applies Tailwind dark class to document root
*/
import { createContext, useContext, useEffect, useMemo, useState, useCallback, ReactNode } from 'react';
import { createContext, useContext, useEffect, useMemo, useState, useCallback, ReactNode, useRef } from 'react';
import { ThemeProvider as MuiThemeProvider, Theme } from '@mui/material/styles';
import { CssBaseline } from '@mui/material';
import { md3LightTheme, md3DarkTheme } from './md3Theme';
@@ -15,8 +15,10 @@ const SETTINGS_STORAGE_KEY = 'motovaultpro-mobile-settings';
interface ThemeContextValue {
isDarkMode: boolean;
toggleDarkMode: () => void;
setDarkMode: (value: boolean) => void;
setDarkMode: (value: boolean, syncToBackend?: boolean) => void;
theme: Theme;
// Callback to register backend sync function
registerBackendSync: (syncFn: (darkMode: boolean) => void) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
@@ -25,13 +27,21 @@ interface ThemeProviderProps {
children: ReactNode;
}
// Read dark mode preference from localStorage (synced with useSettings)
const getStoredDarkMode = (): boolean => {
// Detect system dark mode preference
const getSystemPreference = (): boolean => {
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
};
// Check if user has explicitly set a preference in localStorage
const hasStoredPreference = (): boolean => {
try {
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (stored) {
const settings = JSON.parse(stored);
return settings.darkMode ?? false;
return settings.darkMode !== undefined && settings.darkMode !== null;
}
} catch {
// Ignore parse errors
@@ -39,6 +49,31 @@ const getStoredDarkMode = (): boolean => {
return false;
};
// Read dark mode preference from localStorage
const getStoredDarkMode = (): boolean | undefined => {
try {
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (stored) {
const settings = JSON.parse(stored);
if (settings.darkMode !== undefined && settings.darkMode !== null) {
return settings.darkMode;
}
}
} catch {
// Ignore parse errors
}
return undefined;
};
// Get initial dark mode: localStorage > system preference
const getInitialDarkMode = (): boolean => {
const stored = getStoredDarkMode();
if (stored !== undefined) {
return stored;
}
return getSystemPreference();
};
// Update dark mode in localStorage while preserving other settings
const setStoredDarkMode = (darkMode: boolean): void => {
try {
@@ -52,15 +87,33 @@ const setStoredDarkMode = (darkMode: boolean): void => {
};
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [isDarkMode, setIsDarkMode] = useState<boolean>(getStoredDarkMode);
const [isDarkMode, setIsDarkMode] = useState<boolean>(getInitialDarkMode);
const [isInitialized, setIsInitialized] = useState(false);
const backendSyncRef = useRef<((darkMode: boolean) => void) | null>(null);
// Initialize on mount
useEffect(() => {
setIsDarkMode(getStoredDarkMode());
setIsDarkMode(getInitialDarkMode());
setIsInitialized(true);
}, []);
// Listen for system preference changes (only when no explicit preference set)
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
// Only react to system changes if user hasn't set explicit preference
if (!hasStoredPreference()) {
setIsDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
// Apply dark class to document root for Tailwind
useEffect(() => {
const root = document.documentElement;
@@ -90,13 +143,23 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => {
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
const setDarkMode = useCallback((value: boolean) => {
// Register backend sync function (called by useThemeSync hook)
const registerBackendSync = useCallback((syncFn: (darkMode: boolean) => void) => {
backendSyncRef.current = syncFn;
}, []);
const setDarkMode = useCallback((value: boolean, syncToBackend = true) => {
setIsDarkMode(value);
setStoredDarkMode(value);
// Sync to backend if registered and requested
if (syncToBackend && backendSyncRef.current) {
backendSyncRef.current(value);
}
}, []);
const toggleDarkMode = useCallback(() => {
setDarkMode(!isDarkMode);
setDarkMode(!isDarkMode, true);
}, [isDarkMode, setDarkMode]);
const theme = useMemo(() => {
@@ -109,8 +172,9 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => {
toggleDarkMode,
setDarkMode,
theme,
registerBackendSync,
}),
[isDarkMode, toggleDarkMode, setDarkMode, theme]
[isDarkMode, toggleDarkMode, setDarkMode, theme, registerBackendSync]
);
// Prevent flash of wrong theme during SSR/initial load

View File

@@ -0,0 +1,66 @@
/**
* @ai-summary Hook to sync dark mode preference with backend
* Load from backend on auth, sync changes to backend on toggle
*/
import { useEffect, useRef } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { usePreferences, useUpdatePreferences } from '../../features/settings/hooks/usePreferences';
import { useTheme } from './ThemeContext';
/**
* Syncs theme preference with backend.
* Place this hook in an authenticated component (e.g., Layout or App authenticated section).
*
* Behavior:
* - On auth: loads darkMode from backend and updates ThemeContext
* - On toggle: syncs new darkMode value to backend
*/
export const useThemeSync = () => {
const { isAuthenticated } = useAuth0();
const { data: preferences, isLoading } = usePreferences();
const updatePreferences = useUpdatePreferences();
const { setDarkMode, registerBackendSync, isDarkMode } = useTheme();
const hasLoadedFromBackend = useRef(false);
// Register backend sync function
useEffect(() => {
const syncToBackend = (darkMode: boolean) => {
if (isAuthenticated) {
updatePreferences.mutate({ darkMode });
}
};
registerBackendSync(syncToBackend);
}, [isAuthenticated, updatePreferences, registerBackendSync]);
// Load from backend on initial auth (only once)
useEffect(() => {
if (
isAuthenticated &&
!isLoading &&
preferences &&
!hasLoadedFromBackend.current
) {
hasLoadedFromBackend.current = true;
// Only update if backend has an explicit preference
if (preferences.darkMode !== null && preferences.darkMode !== undefined) {
// Update local state without syncing back to backend
setDarkMode(preferences.darkMode, false);
}
}
}, [isAuthenticated, isLoading, preferences, setDarkMode]);
// Reset flag when user logs out
useEffect(() => {
if (!isAuthenticated) {
hasLoadedFromBackend.current = false;
}
}, [isAuthenticated]);
return {
isDarkMode,
isLoading: isLoading && isAuthenticated,
};
};

Binary file not shown.