1341 lines
40 KiB
Markdown
1341 lines
40 KiB
Markdown
# Code Examples & Implementation Snippets
|
|
|
|
## Overview
|
|
This document provides concrete code examples and implementation snippets for all mobile optimization improvements. These examples can be directly used during implementation with minimal modifications.
|
|
|
|
## Mobile Settings Implementation
|
|
|
|
### Complete Mobile Settings Screen
|
|
**File**: `frontend/src/features/settings/mobile/MobileSettingsScreen.tsx`
|
|
|
|
```tsx
|
|
import React, { useState } from 'react';
|
|
import { useAuth0 } from '@auth0/auth0-react';
|
|
import {
|
|
GlassCard,
|
|
MobileContainer,
|
|
MobilePill
|
|
} from '../../../shared-minimal/components/mobile';
|
|
import { useSettings } from '../hooks/useSettings';
|
|
|
|
interface ToggleSwitchProps {
|
|
enabled: boolean;
|
|
onChange: () => void;
|
|
label: string;
|
|
description?: string;
|
|
}
|
|
|
|
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|
enabled,
|
|
onChange,
|
|
label,
|
|
description
|
|
}) => (
|
|
<div className="flex items-center justify-between py-2">
|
|
<div>
|
|
<p className="font-medium text-slate-800">{label}</p>
|
|
{description && (
|
|
<p className="text-sm text-slate-500">{description}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={onChange}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
enabled ? 'bg-blue-600' : 'bg-gray-200'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
interface ModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
|
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
|
|
{children}
|
|
<div className="flex justify-end mt-4">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const MobileSettingsScreen: React.FC = () => {
|
|
const { user, logout } = useAuth0();
|
|
const { settings, updateSetting } = useSettings();
|
|
const [showDataExport, setShowDataExport] = useState(false);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
|
|
const handleLogout = () => {
|
|
logout({
|
|
logoutParams: {
|
|
returnTo: window.location.origin
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleExportData = () => {
|
|
// Implement data export functionality
|
|
console.log('Exporting user data...');
|
|
setShowDataExport(false);
|
|
};
|
|
|
|
const handleDeleteAccount = () => {
|
|
// Implement account deletion
|
|
console.log('Deleting account...');
|
|
setShowDeleteConfirm(false);
|
|
};
|
|
|
|
return (
|
|
<MobileContainer>
|
|
<div className="space-y-4 pb-20">
|
|
{/* Header */}
|
|
<div className="text-center mb-6">
|
|
<h1 className="text-2xl font-bold text-slate-800">Settings</h1>
|
|
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
|
</div>
|
|
|
|
{/* Account Section */}
|
|
<GlassCard>
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account</h2>
|
|
<div className="flex items-center space-x-3">
|
|
{user?.picture && (
|
|
<img
|
|
src={user.picture}
|
|
alt="Profile"
|
|
className="w-12 h-12 rounded-full"
|
|
/>
|
|
)}
|
|
<div>
|
|
<p className="font-medium text-slate-800">{user?.name}</p>
|
|
<p className="text-sm text-slate-500">{user?.email}</p>
|
|
</div>
|
|
</div>
|
|
<div className="pt-3 mt-3 border-t border-slate-200">
|
|
<p className="text-sm text-slate-600">
|
|
Member since {user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'Unknown'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Notifications Section */}
|
|
<GlassCard>
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Notifications</h2>
|
|
<div className="space-y-3">
|
|
<ToggleSwitch
|
|
enabled={settings.notifications.email}
|
|
onChange={() => updateSetting('notifications', {
|
|
...settings.notifications,
|
|
email: !settings.notifications.email
|
|
})}
|
|
label="Email Notifications"
|
|
description="Receive updates via email"
|
|
/>
|
|
<ToggleSwitch
|
|
enabled={settings.notifications.push}
|
|
onChange={() => updateSetting('notifications', {
|
|
...settings.notifications,
|
|
push: !settings.notifications.push
|
|
})}
|
|
label="Push Notifications"
|
|
description="Receive mobile push notifications"
|
|
/>
|
|
<ToggleSwitch
|
|
enabled={settings.notifications.maintenance}
|
|
onChange={() => updateSetting('notifications', {
|
|
...settings.notifications,
|
|
maintenance: !settings.notifications.maintenance
|
|
})}
|
|
label="Maintenance Reminders"
|
|
description="Get reminded about vehicle maintenance"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Appearance & Units Section */}
|
|
<GlassCard>
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Appearance & Units</h2>
|
|
<div className="space-y-4">
|
|
<ToggleSwitch
|
|
enabled={settings.darkMode}
|
|
onChange={() => updateSetting('darkMode', !settings.darkMode)}
|
|
label="Dark Mode"
|
|
description="Switch to dark theme"
|
|
/>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium text-slate-800">Unit System</p>
|
|
<p className="text-sm text-slate-500">
|
|
Currently using {settings.unitSystem === 'imperial' ? 'Miles & Gallons' : 'Kilometers & Liters'}
|
|
</p>
|
|
</div>
|
|
<MobilePill
|
|
label={settings.unitSystem === 'imperial' ? 'Metric' : 'Imperial'}
|
|
onClick={() => updateSetting('unitSystem', settings.unitSystem === 'imperial' ? 'metric' : 'imperial')}
|
|
variant="secondary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Data Management Section */}
|
|
<GlassCard>
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Data Management</h2>
|
|
<div className="space-y-3">
|
|
<button
|
|
onClick={() => setShowDataExport(true)}
|
|
className="w-full text-left p-3 bg-blue-50 text-blue-700 rounded-lg font-medium"
|
|
>
|
|
Export My Data
|
|
</button>
|
|
<p className="text-sm text-slate-500">
|
|
Download a copy of all your vehicle and fuel data
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Account Actions Section */}
|
|
<GlassCard>
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account Actions</h2>
|
|
<div className="space-y-3">
|
|
<button
|
|
onClick={handleLogout}
|
|
className="w-full py-3 px-4 bg-gray-100 text-gray-700 rounded-lg text-left font-medium"
|
|
>
|
|
Sign Out
|
|
</button>
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="w-full py-3 px-4 bg-red-50 text-red-600 rounded-lg text-left font-medium"
|
|
>
|
|
Delete Account
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Data Export Modal */}
|
|
<Modal
|
|
isOpen={showDataExport}
|
|
onClose={() => setShowDataExport(false)}
|
|
title="Export Data"
|
|
>
|
|
<p className="text-slate-600 mb-4">
|
|
This will create a downloadable file containing all your vehicle data, fuel logs, and preferences.
|
|
</p>
|
|
<div className="flex space-x-3">
|
|
<button
|
|
onClick={() => setShowDataExport(false)}
|
|
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleExportData}
|
|
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium"
|
|
>
|
|
Export
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Delete Account Confirmation */}
|
|
<Modal
|
|
isOpen={showDeleteConfirm}
|
|
onClose={() => setShowDeleteConfirm(false)}
|
|
title="Delete Account"
|
|
>
|
|
<p className="text-slate-600 mb-4">
|
|
This action cannot be undone. All your data will be permanently deleted.
|
|
</p>
|
|
<div className="flex space-x-3">
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleDeleteAccount}
|
|
className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
};
|
|
```
|
|
|
|
## State Management Examples
|
|
|
|
### Enhanced Navigation Store
|
|
**File**: `frontend/src/core/store/navigation.ts`
|
|
|
|
```tsx
|
|
import { create } from 'zustand';
|
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
|
|
export type MobileScreen = 'dashboard' | 'vehicles' | 'fuel' | 'settings';
|
|
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
|
|
|
interface NavigationHistory {
|
|
screen: MobileScreen;
|
|
vehicleSubScreen?: VehicleSubScreen;
|
|
selectedVehicleId?: string | null;
|
|
timestamp: number;
|
|
metadata?: Record<string, any>;
|
|
}
|
|
|
|
interface FormState {
|
|
data: Record<string, any>;
|
|
timestamp: number;
|
|
isDirty: boolean;
|
|
}
|
|
|
|
interface NavigationState {
|
|
// Current navigation state
|
|
activeScreen: MobileScreen;
|
|
vehicleSubScreen: VehicleSubScreen;
|
|
selectedVehicleId: string | null;
|
|
|
|
// Navigation history for back button
|
|
navigationHistory: NavigationHistory[];
|
|
|
|
// Form state preservation
|
|
formStates: Record<string, FormState>;
|
|
|
|
// Loading and error states
|
|
isNavigating: boolean;
|
|
navigationError: string | null;
|
|
|
|
// Actions
|
|
navigateToScreen: (screen: MobileScreen, metadata?: Record<string, any>) => void;
|
|
navigateToVehicleSubScreen: (subScreen: VehicleSubScreen, vehicleId?: string, metadata?: Record<string, any>) => void;
|
|
goBack: () => boolean;
|
|
canGoBack: () => boolean;
|
|
saveFormState: (formId: string, data: any, isDirty?: boolean) => void;
|
|
restoreFormState: (formId: string) => FormState | null;
|
|
clearFormState: (formId: string) => void;
|
|
clearAllFormStates: () => void;
|
|
setNavigationError: (error: string | null) => void;
|
|
}
|
|
|
|
export const useNavigationStore = create<NavigationState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
// Initial state
|
|
activeScreen: 'vehicles',
|
|
vehicleSubScreen: 'list',
|
|
selectedVehicleId: null,
|
|
navigationHistory: [],
|
|
formStates: {},
|
|
isNavigating: false,
|
|
navigationError: null,
|
|
|
|
// Navigation actions
|
|
navigateToScreen: (screen, metadata = {}) => {
|
|
const currentState = get();
|
|
|
|
set({ isNavigating: true, navigationError: null });
|
|
|
|
try {
|
|
const historyEntry: NavigationHistory = {
|
|
screen: currentState.activeScreen,
|
|
vehicleSubScreen: currentState.vehicleSubScreen,
|
|
selectedVehicleId: currentState.selectedVehicleId,
|
|
timestamp: Date.now(),
|
|
metadata,
|
|
};
|
|
|
|
set({
|
|
activeScreen: screen,
|
|
vehicleSubScreen: screen === 'vehicles' ? 'list' : currentState.vehicleSubScreen,
|
|
selectedVehicleId: screen === 'vehicles' ? currentState.selectedVehicleId : null,
|
|
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
|
|
isNavigating: false,
|
|
});
|
|
} catch (error) {
|
|
set({
|
|
navigationError: error instanceof Error ? error.message : 'Navigation failed',
|
|
isNavigating: false
|
|
});
|
|
}
|
|
},
|
|
|
|
navigateToVehicleSubScreen: (subScreen, vehicleId = null, metadata = {}) => {
|
|
const currentState = get();
|
|
|
|
set({ isNavigating: true, navigationError: null });
|
|
|
|
try {
|
|
const historyEntry: NavigationHistory = {
|
|
screen: currentState.activeScreen,
|
|
vehicleSubScreen: currentState.vehicleSubScreen,
|
|
selectedVehicleId: currentState.selectedVehicleId,
|
|
timestamp: Date.now(),
|
|
metadata,
|
|
};
|
|
|
|
set({
|
|
vehicleSubScreen: subScreen,
|
|
selectedVehicleId: vehicleId || currentState.selectedVehicleId,
|
|
navigationHistory: [...currentState.navigationHistory, historyEntry].slice(-10),
|
|
isNavigating: false,
|
|
});
|
|
} catch (error) {
|
|
set({
|
|
navigationError: error instanceof Error ? error.message : 'Navigation failed',
|
|
isNavigating: false
|
|
});
|
|
}
|
|
},
|
|
|
|
goBack: () => {
|
|
const currentState = get();
|
|
const lastEntry = currentState.navigationHistory[currentState.navigationHistory.length - 1];
|
|
|
|
if (lastEntry) {
|
|
set({
|
|
activeScreen: lastEntry.screen,
|
|
vehicleSubScreen: lastEntry.vehicleSubScreen || 'list',
|
|
selectedVehicleId: lastEntry.selectedVehicleId,
|
|
navigationHistory: currentState.navigationHistory.slice(0, -1),
|
|
isNavigating: false,
|
|
navigationError: null,
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
canGoBack: () => {
|
|
return get().navigationHistory.length > 0;
|
|
},
|
|
|
|
// Form state management
|
|
saveFormState: (formId, data, isDirty = true) => {
|
|
const currentState = get();
|
|
const formState: FormState = {
|
|
data,
|
|
timestamp: Date.now(),
|
|
isDirty,
|
|
};
|
|
|
|
set({
|
|
formStates: {
|
|
...currentState.formStates,
|
|
[formId]: formState,
|
|
},
|
|
});
|
|
},
|
|
|
|
restoreFormState: (formId) => {
|
|
const state = get().formStates[formId];
|
|
const maxAge = 2 * 60 * 60 * 1000; // 2 hours
|
|
|
|
if (state && Date.now() - state.timestamp < maxAge) {
|
|
return state;
|
|
}
|
|
|
|
// Clean up old state
|
|
if (state) {
|
|
get().clearFormState(formId);
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
clearFormState: (formId) => {
|
|
const currentState = get();
|
|
const newFormStates = { ...currentState.formStates };
|
|
delete newFormStates[formId];
|
|
set({ formStates: newFormStates });
|
|
},
|
|
|
|
clearAllFormStates: () => {
|
|
set({ formStates: {} });
|
|
},
|
|
|
|
setNavigationError: (error) => {
|
|
set({ navigationError: error });
|
|
},
|
|
}),
|
|
{
|
|
name: 'motovaultpro-mobile-navigation',
|
|
storage: createJSONStorage(() => localStorage),
|
|
partialize: (state) => ({
|
|
activeScreen: state.activeScreen,
|
|
vehicleSubScreen: state.vehicleSubScreen,
|
|
selectedVehicleId: state.selectedVehicleId,
|
|
formStates: state.formStates,
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
```
|
|
|
|
### Form State Hook Implementation
|
|
**File**: `frontend/src/core/hooks/useFormState.ts`
|
|
|
|
```tsx
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useNavigationStore } from '../store/navigation';
|
|
import { debounce } from 'lodash';
|
|
|
|
export interface UseFormStateOptions<T> {
|
|
formId: string;
|
|
defaultValues: T;
|
|
autoSave?: boolean;
|
|
saveDelay?: number;
|
|
onRestore?: (data: T) => void;
|
|
onSave?: (data: T) => void;
|
|
validate?: (data: T) => Record<string, string> | null;
|
|
}
|
|
|
|
export interface FormStateReturn<T> {
|
|
formData: T;
|
|
updateFormData: (updates: Partial<T>) => void;
|
|
setFormData: (data: T) => void;
|
|
resetForm: () => void;
|
|
submitForm: () => Promise<void>;
|
|
hasChanges: boolean;
|
|
isRestored: boolean;
|
|
isSaving: boolean;
|
|
errors: Record<string, string>;
|
|
isValid: boolean;
|
|
}
|
|
|
|
export const useFormState = <T extends Record<string, any>>({
|
|
formId,
|
|
defaultValues,
|
|
autoSave = true,
|
|
saveDelay = 1000,
|
|
onRestore,
|
|
onSave,
|
|
validate,
|
|
}: UseFormStateOptions<T>): FormStateReturn<T> => {
|
|
const { saveFormState, restoreFormState, clearFormState } = useNavigationStore();
|
|
const [formData, setFormDataState] = useState<T>(defaultValues);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
const [isRestored, setIsRestored] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
const initialDataRef = useRef<T>(defaultValues);
|
|
const formDataRef = useRef<T>(formData);
|
|
|
|
// Update ref when formData changes
|
|
useEffect(() => {
|
|
formDataRef.current = formData;
|
|
}, [formData]);
|
|
|
|
// Validation
|
|
const validateForm = useCallback((data: T) => {
|
|
if (!validate) return {};
|
|
|
|
const validationErrors = validate(data);
|
|
return validationErrors || {};
|
|
}, [validate]);
|
|
|
|
// Restore form state on mount
|
|
useEffect(() => {
|
|
const restoredState = restoreFormState(formId);
|
|
if (restoredState && !isRestored) {
|
|
const restoredData = { ...defaultValues, ...restoredState.data };
|
|
setFormDataState(restoredData);
|
|
setHasChanges(restoredState.isDirty);
|
|
setIsRestored(true);
|
|
|
|
if (onRestore) {
|
|
onRestore(restoredData);
|
|
}
|
|
}
|
|
}, [formId, restoreFormState, defaultValues, isRestored, onRestore]);
|
|
|
|
// Auto-save with debounce
|
|
const debouncedSave = useCallback(
|
|
debounce(async (data: T, isDirty: boolean) => {
|
|
if (!autoSave || !isDirty) return;
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
saveFormState(formId, data, isDirty);
|
|
|
|
if (onSave) {
|
|
await onSave(data);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Form auto-save failed:', error);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, saveDelay),
|
|
[autoSave, saveDelay, formId, saveFormState, onSave]
|
|
);
|
|
|
|
// Trigger auto-save when form data changes
|
|
useEffect(() => {
|
|
if (hasChanges) {
|
|
const validationErrors = validateForm(formData);
|
|
setErrors(validationErrors);
|
|
debouncedSave(formData, hasChanges);
|
|
}
|
|
}, [formData, hasChanges, validateForm, debouncedSave]);
|
|
|
|
const updateFormData = useCallback((updates: Partial<T>) => {
|
|
setFormDataState((current) => {
|
|
const updated = { ...current, ...updates };
|
|
const hasActualChanges = JSON.stringify(updated) !== JSON.stringify(initialDataRef.current);
|
|
setHasChanges(hasActualChanges);
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
const setFormData = useCallback((data: T) => {
|
|
setFormDataState(data);
|
|
const hasActualChanges = JSON.stringify(data) !== JSON.stringify(initialDataRef.current);
|
|
setHasChanges(hasActualChanges);
|
|
}, []);
|
|
|
|
const resetForm = useCallback(() => {
|
|
setFormDataState(defaultValues);
|
|
setHasChanges(false);
|
|
setErrors({});
|
|
clearFormState(formId);
|
|
initialDataRef.current = { ...defaultValues };
|
|
}, [defaultValues, formId, clearFormState]);
|
|
|
|
const submitForm = useCallback(async () => {
|
|
const validationErrors = validateForm(formDataRef.current);
|
|
setErrors(validationErrors);
|
|
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
throw new Error('Form validation failed');
|
|
}
|
|
|
|
try {
|
|
setHasChanges(false);
|
|
clearFormState(formId);
|
|
initialDataRef.current = { ...formDataRef.current };
|
|
|
|
if (onSave) {
|
|
await onSave(formDataRef.current);
|
|
}
|
|
} catch (error) {
|
|
setHasChanges(true); // Restore changes state on error
|
|
throw error;
|
|
}
|
|
}, [validateForm, formId, clearFormState, onSave]);
|
|
|
|
const isValid = Object.keys(errors).length === 0;
|
|
|
|
return {
|
|
formData,
|
|
updateFormData,
|
|
setFormData,
|
|
resetForm,
|
|
submitForm,
|
|
hasChanges,
|
|
isRestored,
|
|
isSaving,
|
|
errors,
|
|
isValid,
|
|
};
|
|
};
|
|
```
|
|
|
|
## Token Management Examples
|
|
|
|
### Enhanced API Client with 401 Retry
|
|
**File**: `frontend/src/core/api/enhancedClient.ts`
|
|
|
|
```tsx
|
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
|
|
interface TokenManager {
|
|
refreshToken(): Promise<string>;
|
|
isRefreshing(): boolean;
|
|
addFailedRequest(request: () => Promise<any>): void;
|
|
}
|
|
|
|
class AuthTokenManager implements TokenManager {
|
|
private static instance: AuthTokenManager;
|
|
private _isRefreshing = false;
|
|
private failedQueue: Array<{
|
|
resolve: (token: string) => void;
|
|
reject: (error: Error) => void;
|
|
}> = [];
|
|
private getAccessTokenSilently: any;
|
|
|
|
constructor(getAccessTokenSilently: any) {
|
|
this.getAccessTokenSilently = getAccessTokenSilently;
|
|
}
|
|
|
|
static getInstance(getAccessTokenSilently?: any): AuthTokenManager {
|
|
if (!AuthTokenManager.instance && getAccessTokenSilently) {
|
|
AuthTokenManager.instance = new AuthTokenManager(getAccessTokenSilently);
|
|
}
|
|
return AuthTokenManager.instance;
|
|
}
|
|
|
|
async refreshToken(): Promise<string> {
|
|
if (this._isRefreshing) {
|
|
return new Promise((resolve, reject) => {
|
|
this.failedQueue.push({ resolve, reject });
|
|
});
|
|
}
|
|
|
|
this._isRefreshing = true;
|
|
|
|
try {
|
|
const token = await this.getAccessTokenSilently({
|
|
cacheMode: 'off',
|
|
timeoutInSeconds: 20,
|
|
});
|
|
|
|
// Process queued requests
|
|
this.failedQueue.forEach(({ resolve }) => resolve(token));
|
|
this.failedQueue = [];
|
|
|
|
return token;
|
|
} catch (error) {
|
|
// Reject queued requests
|
|
this.failedQueue.forEach(({ reject }) => reject(error as Error));
|
|
this.failedQueue = [];
|
|
throw error;
|
|
} finally {
|
|
this._isRefreshing = false;
|
|
}
|
|
}
|
|
|
|
isRefreshing(): boolean {
|
|
return this._isRefreshing;
|
|
}
|
|
|
|
addFailedRequest(callback: () => Promise<any>): void {
|
|
this.failedQueue.push({
|
|
resolve: () => callback(),
|
|
reject: (error) => Promise.reject(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
export const createEnhancedApiClient = (getAccessTokenSilently: any): AxiosInstance => {
|
|
const tokenManager = AuthTokenManager.getInstance(getAccessTokenSilently);
|
|
|
|
const client = axios.create({
|
|
baseURL: process.env.REACT_APP_API_URL || '/api',
|
|
timeout: 10000,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
// Request interceptor - inject tokens
|
|
client.interceptors.request.use(
|
|
async (config) => {
|
|
try {
|
|
// Don't add token if already refreshing or if this is a retry
|
|
if (!config._retry && !tokenManager.isRefreshing()) {
|
|
const token = await getAccessTokenSilently({
|
|
cacheMode: 'on',
|
|
timeoutInSeconds: 15,
|
|
});
|
|
|
|
if (token) {
|
|
config.headers = config.headers || {};
|
|
config.headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Token acquisition failed, proceeding without token:', error);
|
|
}
|
|
|
|
return config;
|
|
},
|
|
(error) => Promise.reject(error)
|
|
);
|
|
|
|
// Response interceptor - handle 401s with token refresh retry
|
|
client.interceptors.response.use(
|
|
(response: AxiosResponse) => response,
|
|
async (error) => {
|
|
const originalRequest = error.config;
|
|
|
|
// Handle 401 responses with token refresh
|
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
|
originalRequest._retry = true;
|
|
|
|
try {
|
|
console.log('401 detected, attempting token refresh...');
|
|
const newToken = await tokenManager.refreshToken();
|
|
|
|
// Update the failed request with new token
|
|
originalRequest.headers = originalRequest.headers || {};
|
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
|
|
// Retry the original request
|
|
return client(originalRequest);
|
|
} catch (refreshError) {
|
|
console.error('Token refresh failed:', refreshError);
|
|
|
|
// Clear any stored tokens and redirect to login
|
|
localStorage.clear();
|
|
window.location.href = '/';
|
|
|
|
return Promise.reject(refreshError);
|
|
}
|
|
}
|
|
|
|
// Enhanced mobile error handling
|
|
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
|
|
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
|
navigator.userAgent
|
|
);
|
|
|
|
if (isMobile) {
|
|
error.message = 'Connection timeout. Please check your network and try again.';
|
|
}
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
return client;
|
|
};
|
|
|
|
// Usage example
|
|
export const useApiClient = () => {
|
|
const { getAccessTokenSilently } = useAuth0();
|
|
|
|
return useMemo(() => {
|
|
return createEnhancedApiClient(getAccessTokenSilently);
|
|
}, [getAccessTokenSilently]);
|
|
};
|
|
```
|
|
|
|
## Mobile Component Examples
|
|
|
|
### Enhanced Add Vehicle Form with State Persistence
|
|
**File**: `frontend/src/features/vehicles/mobile/EnhancedAddVehicleScreen.tsx`
|
|
|
|
```tsx
|
|
import React, { useCallback } from 'react';
|
|
import { ArrowLeft, Save } from 'lucide-react';
|
|
import { useFormState } from '../../../core/hooks/useFormState';
|
|
import { useNavigationStore } from '../../../core/store/navigation';
|
|
import { GlassCard, MobileContainer, MobilePill } from '../../../shared-minimal/components/mobile';
|
|
|
|
interface VehicleFormData {
|
|
year: string;
|
|
make: string;
|
|
model: string;
|
|
trim: string;
|
|
vin: string;
|
|
licensePlate: string;
|
|
nickname: string;
|
|
color: string;
|
|
}
|
|
|
|
interface EnhancedAddVehicleScreenProps {
|
|
onVehicleAdded: () => void;
|
|
}
|
|
|
|
export const EnhancedAddVehicleScreen: React.FC<EnhancedAddVehicleScreenProps> = ({
|
|
onVehicleAdded,
|
|
}) => {
|
|
const { goBack } = useNavigationStore();
|
|
|
|
const validateForm = useCallback((data: VehicleFormData) => {
|
|
const errors: Record<string, string> = {};
|
|
|
|
if (!data.year || parseInt(data.year) < 1900 || parseInt(data.year) > new Date().getFullYear() + 1) {
|
|
errors.year = 'Please enter a valid year';
|
|
}
|
|
|
|
if (!data.make.trim()) {
|
|
errors.make = 'Make is required';
|
|
}
|
|
|
|
if (!data.model.trim()) {
|
|
errors.model = 'Model is required';
|
|
}
|
|
|
|
if (data.vin && data.vin.length !== 17) {
|
|
errors.vin = 'VIN must be 17 characters';
|
|
}
|
|
|
|
if (!data.vin.trim() && !data.licensePlate.trim()) {
|
|
errors.licensePlate = 'Either VIN or License Plate is required';
|
|
}
|
|
|
|
return Object.keys(errors).length > 0 ? errors : null;
|
|
}, []);
|
|
|
|
const {
|
|
formData,
|
|
updateFormData,
|
|
resetForm,
|
|
submitForm,
|
|
hasChanges,
|
|
isRestored,
|
|
isSaving,
|
|
errors,
|
|
isValid,
|
|
} = useFormState<VehicleFormData>({
|
|
formId: 'add-vehicle',
|
|
defaultValues: {
|
|
year: '',
|
|
make: '',
|
|
model: '',
|
|
trim: '',
|
|
vin: '',
|
|
licensePlate: '',
|
|
nickname: '',
|
|
color: '',
|
|
},
|
|
validate: validateForm,
|
|
onRestore: (data) => {
|
|
console.log('Form data restored:', data);
|
|
},
|
|
});
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!isValid) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Simulate API call
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
await submitForm();
|
|
onVehicleAdded();
|
|
} catch (error) {
|
|
console.error('Error adding vehicle:', error);
|
|
// Form state is preserved on error
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
if (hasChanges) {
|
|
const confirmLeave = window.confirm(
|
|
'You have unsaved changes. Are you sure you want to leave? Your changes will be saved as a draft.'
|
|
);
|
|
if (!confirmLeave) return;
|
|
}
|
|
|
|
goBack();
|
|
};
|
|
|
|
return (
|
|
<MobileContainer>
|
|
<div className="space-y-4 pb-20">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center">
|
|
<button onClick={handleBack} className="mr-4">
|
|
<ArrowLeft className="w-6 h-6 text-slate-700" />
|
|
</button>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-slate-800">Add Vehicle</h1>
|
|
{isRestored && (
|
|
<p className="text-sm text-blue-600">Draft restored</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isSaving && (
|
|
<div className="flex items-center text-sm text-blue-600">
|
|
<Save className="w-4 h-4 mr-1" />
|
|
Saving...
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Basic Information */}
|
|
<GlassCard>
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Basic Information</h2>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Year *
|
|
</label>
|
|
<input
|
|
type="number"
|
|
placeholder="2023"
|
|
value={formData.year}
|
|
onChange={(e) => updateFormData({ year: e.target.value })}
|
|
className={`w-full p-3 border rounded-lg ${
|
|
errors.year ? 'border-red-300' : 'border-slate-300'
|
|
}`}
|
|
/>
|
|
{errors.year && (
|
|
<p className="text-sm text-red-600 mt-1">{errors.year}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Make *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Toyota"
|
|
value={formData.make}
|
|
onChange={(e) => updateFormData({ make: e.target.value })}
|
|
className={`w-full p-3 border rounded-lg ${
|
|
errors.make ? 'border-red-300' : 'border-slate-300'
|
|
}`}
|
|
/>
|
|
{errors.make && (
|
|
<p className="text-sm text-red-600 mt-1">{errors.make}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Model *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Camry"
|
|
value={formData.model}
|
|
onChange={(e) => updateFormData({ model: e.target.value })}
|
|
className={`w-full p-3 border rounded-lg ${
|
|
errors.model ? 'border-red-300' : 'border-slate-300'
|
|
}`}
|
|
/>
|
|
{errors.model && (
|
|
<p className="text-sm text-red-600 mt-1">{errors.model}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Trim
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="LE, XLE, etc."
|
|
value={formData.trim}
|
|
onChange={(e) => updateFormData({ trim: e.target.value })}
|
|
className="w-full p-3 border border-slate-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Identification */}
|
|
<GlassCard>
|
|
<div className="p-4">
|
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Identification</h2>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
VIN
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="17-character VIN"
|
|
value={formData.vin}
|
|
onChange={(e) => updateFormData({ vin: e.target.value.toUpperCase() })}
|
|
maxLength={17}
|
|
className={`w-full p-3 border rounded-lg font-mono text-sm ${
|
|
errors.vin ? 'border-red-300' : 'border-slate-300'
|
|
}`}
|
|
/>
|
|
{errors.vin && (
|
|
<p className="text-sm text-red-600 mt-1">{errors.vin}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
License Plate
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="ABC-123"
|
|
value={formData.licensePlate}
|
|
onChange={(e) => updateFormData({ licensePlate: e.target.value.toUpperCase() })}
|
|
className={`w-full p-3 border rounded-lg ${
|
|
errors.licensePlate ? 'border-red-300' : 'border-slate-300'
|
|
}`}
|
|
/>
|
|
{errors.licensePlate && (
|
|
<p className="text-sm text-red-600 mt-1">{errors.licensePlate}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Nickname
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="My Daily Driver"
|
|
value={formData.nickname}
|
|
onChange={(e) => updateFormData({ nickname: e.target.value })}
|
|
className="w-full p-3 border border-slate-300 rounded-lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Form Actions */}
|
|
<div className="flex space-x-3 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={resetForm}
|
|
className="flex-1 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
|
>
|
|
Clear All
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!isValid}
|
|
className={`flex-1 py-3 rounded-lg font-medium ${
|
|
isValid
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
Add Vehicle
|
|
</button>
|
|
</div>
|
|
|
|
{hasChanges && (
|
|
<p className="text-sm text-blue-600 text-center bg-blue-50 p-2 rounded-lg">
|
|
Changes are being saved automatically
|
|
</p>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
};
|
|
```
|
|
|
|
## App Integration Examples
|
|
|
|
### Updated App.tsx with Enhanced Navigation
|
|
**File**: `frontend/src/App.tsx` (key sections)
|
|
|
|
```tsx
|
|
import React, { useEffect } from 'react';
|
|
import { useAuth0 } from '@auth0/auth0-react';
|
|
import { useNavigationStore } from './core/store/navigation';
|
|
import { useUserStore } from './core/store/user';
|
|
import { MobileSettingsScreen } from './features/settings/mobile/MobileSettingsScreen';
|
|
import { EnhancedAddVehicleScreen } from './features/vehicles/mobile/EnhancedAddVehicleScreen';
|
|
|
|
const MobileApp: React.FC = () => {
|
|
const { user, isAuthenticated, isLoading } = useAuth0();
|
|
const {
|
|
activeScreen,
|
|
vehicleSubScreen,
|
|
selectedVehicleId,
|
|
navigateToScreen,
|
|
navigateToVehicleSubScreen,
|
|
goBack,
|
|
canGoBack,
|
|
navigationError,
|
|
} = useNavigationStore();
|
|
|
|
const { setUserProfile } = useUserStore();
|
|
|
|
// Update user profile when authenticated
|
|
useEffect(() => {
|
|
if (isAuthenticated && user) {
|
|
setUserProfile(user);
|
|
}
|
|
}, [isAuthenticated, user, setUserProfile]);
|
|
|
|
// Handle mobile back button and navigation errors
|
|
useEffect(() => {
|
|
const handlePopState = (event: PopStateEvent) => {
|
|
event.preventDefault();
|
|
if (canGoBack()) {
|
|
goBack();
|
|
}
|
|
};
|
|
|
|
const handleNavigationError = () => {
|
|
if (navigationError) {
|
|
console.error('Navigation error:', navigationError);
|
|
// Could show toast notification here
|
|
}
|
|
};
|
|
|
|
window.addEventListener('popstate', handlePopState);
|
|
handleNavigationError();
|
|
|
|
return () => {
|
|
window.removeEventListener('popstate', handlePopState);
|
|
};
|
|
}, [goBack, canGoBack, navigationError]);
|
|
|
|
const handleVehicleSelect = useCallback((vehicleId: string) => {
|
|
navigateToVehicleSubScreen('detail', vehicleId, { source: 'vehicle-list' });
|
|
}, [navigateToVehicleSubScreen]);
|
|
|
|
const handleAddVehicle = useCallback(() => {
|
|
navigateToVehicleSubScreen('add', null, { source: 'vehicle-list' });
|
|
}, [navigateToVehicleSubScreen]);
|
|
|
|
const handleBackToList = useCallback(() => {
|
|
navigateToVehicleSubScreen('list', null, { source: 'back-navigation' });
|
|
}, [navigateToVehicleSubScreen]);
|
|
|
|
const handleVehicleAdded = useCallback(() => {
|
|
navigateToVehicleSubScreen('list', null, { source: 'vehicle-added' });
|
|
}, [navigateToVehicleSubScreen]);
|
|
|
|
// Show loading screen while Auth0 initializes
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-slate-600">Loading MotoVaultPro...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show login screen if not authenticated
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4">
|
|
<div className="max-w-md w-full text-center">
|
|
<h1 className="text-3xl font-bold text-slate-800 mb-2">MotoVaultPro</h1>
|
|
<p className="text-slate-600 mb-8">Track your vehicles and fuel efficiency</p>
|
|
<button
|
|
onClick={() => loginWithRedirect()}
|
|
className="w-full py-3 px-6 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
|
>
|
|
Sign In
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Main mobile app interface
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
|
|
{/* Navigation Error Banner */}
|
|
{navigationError && (
|
|
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4">
|
|
<p className="font-medium">Navigation Error</p>
|
|
<p className="text-sm">{navigationError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Screen Content */}
|
|
{renderActiveScreen()}
|
|
|
|
{/* Bottom Navigation */}
|
|
<BottomNavigation
|
|
activeScreen={activeScreen}
|
|
onScreenChange={navigateToScreen}
|
|
hasChanges={hasUnsavedChanges()} // Could implement this to show unsaved indicators
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
// Screen rendering logic
|
|
function renderActiveScreen() {
|
|
switch (activeScreen) {
|
|
case 'vehicles':
|
|
return renderVehiclesScreen();
|
|
case 'fuel':
|
|
return <FuelScreen />;
|
|
case 'dashboard':
|
|
return <DashboardScreen />;
|
|
case 'settings':
|
|
return <MobileSettingsScreen />;
|
|
default:
|
|
return renderVehiclesScreen();
|
|
}
|
|
}
|
|
|
|
function renderVehiclesScreen() {
|
|
switch (vehicleSubScreen) {
|
|
case 'list':
|
|
return (
|
|
<VehiclesMobileScreen
|
|
onVehicleSelect={handleVehicleSelect}
|
|
onAddVehicle={handleAddVehicle}
|
|
/>
|
|
);
|
|
case 'detail':
|
|
return (
|
|
<VehicleDetailMobile
|
|
vehicleId={selectedVehicleId!}
|
|
onBack={handleBackToList}
|
|
/>
|
|
);
|
|
case 'add':
|
|
return (
|
|
<EnhancedAddVehicleScreen
|
|
onVehicleAdded={handleVehicleAdded}
|
|
/>
|
|
);
|
|
default:
|
|
return (
|
|
<VehiclesMobileScreen
|
|
onVehicleSelect={handleVehicleSelect}
|
|
onAddVehicle={handleAddVehicle}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
These code examples provide concrete, implementable solutions for all aspects of the mobile optimization plan. Each example includes proper error handling, TypeScript types, and integration with the existing architecture. |