feat: delete users - not tested
This commit is contained in:
23
frontend/src/features/onboarding/api/onboarding.api.ts
Normal file
23
frontend/src/features/onboarding/api/onboarding.api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @ai-summary API client for onboarding endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { OnboardingPreferences, OnboardingStatus } from '../types/onboarding.types';
|
||||
|
||||
export const onboardingApi = {
|
||||
savePreferences: async (data: OnboardingPreferences) => {
|
||||
const response = await apiClient.post('/onboarding/preferences', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
completeOnboarding: async () => {
|
||||
const response = await apiClient.post('/onboarding/complete');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getStatus: async () => {
|
||||
const response = await apiClient.get<OnboardingStatus>('/onboarding/status');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
114
frontend/src/features/onboarding/components/AddVehicleStep.tsx
Normal file
114
frontend/src/features/onboarding/components/AddVehicleStep.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @ai-summary Step 2 of onboarding - Optionally add first vehicle
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { VehicleForm } from '../../vehicles/components/VehicleForm';
|
||||
import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface AddVehicleStepProps {
|
||||
onNext: () => void;
|
||||
onAddVehicle: (data: CreateVehicleRequest) => void;
|
||||
onBack: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const AddVehicleStep: React.FC<AddVehicleStepProps> = ({
|
||||
onNext,
|
||||
onAddVehicle,
|
||||
onBack,
|
||||
loading,
|
||||
}) => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const handleSkip = () => {
|
||||
onNext();
|
||||
};
|
||||
|
||||
const handleAddVehicle = (data: CreateVehicleRequest) => {
|
||||
onAddVehicle(data);
|
||||
};
|
||||
|
||||
if (!showForm) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-10 h-10 text-primary-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Add Your First Vehicle</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Add a vehicle now or skip this step and add it later from your garage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Add Vehicle
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSkip}
|
||||
className="w-full min-h-[44px]"
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="min-h-[44px]"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Add Your First Vehicle</h2>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Fill in the details below. You can always edit this later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VehicleForm
|
||||
onSubmit={handleAddVehicle}
|
||||
onCancel={() => setShowForm(false)}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
className="min-h-[44px]"
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
frontend/src/features/onboarding/components/CompleteStep.tsx
Normal file
70
frontend/src/features/onboarding/components/CompleteStep.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @ai-summary Step 3 of onboarding - Success screen
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '../../../shared-minimal/components/Button';
|
||||
|
||||
interface CompleteStepProps {
|
||||
onComplete: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const CompleteStep: React.FC<CompleteStepProps> = ({ onComplete, loading }) => {
|
||||
return (
|
||||
<div className="space-y-6 text-center py-8">
|
||||
<div className="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center animate-bounce">
|
||||
<svg
|
||||
className="w-12 h-12 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-2">You're All Set!</h2>
|
||||
<p className="text-slate-600 max-w-md mx-auto">
|
||||
Welcome to MotoVault Pro. Your account is ready and you can now start tracking your vehicles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-primary-50 rounded-lg p-6 max-w-md mx-auto">
|
||||
<h3 className="font-semibold text-primary-900 mb-2">What's Next?</h3>
|
||||
<ul className="text-left space-y-2 text-sm text-primary-800">
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Add or manage your vehicles in the garage</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Track fuel logs and maintenance records</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<svg className="w-5 h-5 text-primary-600 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Upload important vehicle documents</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<Button onClick={onComplete} loading={loading} className="min-h-[44px] px-8">
|
||||
Go to My Garage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
141
frontend/src/features/onboarding/components/PreferencesStep.tsx
Normal file
141
frontend/src/features/onboarding/components/PreferencesStep.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @ai-summary Step 1 of onboarding - Set user preferences
|
||||
*/
|
||||
|
||||
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 { OnboardingPreferences } from '../types/onboarding.types';
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
unitSystem: z.enum(['imperial', 'metric']),
|
||||
currencyCode: z.string().length(3),
|
||||
timeZone: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
interface PreferencesStepProps {
|
||||
onNext: (data: OnboardingPreferences) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PreferencesStep: React.FC<PreferencesStepProps> = ({ onNext, loading }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<OnboardingPreferences>({
|
||||
resolver: zodResolver(preferencesSchema),
|
||||
defaultValues: {
|
||||
unitSystem: 'imperial',
|
||||
currencyCode: 'USD',
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
});
|
||||
|
||||
const unitSystem = watch('unitSystem');
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-4">Set Your Preferences</h2>
|
||||
<p className="text-slate-600 mb-6">
|
||||
Choose your preferred units and settings to personalize your experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unit System Toggle */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Unit System
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('unitSystem', 'imperial')}
|
||||
className={`min-h-[44px] py-3 px-4 rounded-lg border-2 font-medium transition-all ${
|
||||
unitSystem === 'imperial'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">Imperial</div>
|
||||
<div className="text-xs mt-1">Miles & Gallons</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('unitSystem', 'metric')}
|
||||
className={`min-h-[44px] py-3 px-4 rounded-lg border-2 font-medium transition-all ${
|
||||
unitSystem === 'metric'
|
||||
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">Metric</div>
|
||||
<div className="text-xs mt-1">Kilometers & Liters</div>
|
||||
</button>
|
||||
</div>
|
||||
{errors.unitSystem && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.unitSystem.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Currency Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<select
|
||||
{...register('currencyCode')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
<option value="USD">USD - US Dollar</option>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
<option value="GBP">GBP - British Pound</option>
|
||||
<option value="CAD">CAD - Canadian Dollar</option>
|
||||
<option value="AUD">AUD - Australian Dollar</option>
|
||||
</select>
|
||||
{errors.currencyCode && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.currencyCode.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone Dropdown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Time Zone
|
||||
</label>
|
||||
<select
|
||||
{...register('timeZone')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-[44px]"
|
||||
style={{ fontSize: '16px' }}
|
||||
>
|
||||
<option value="America/New_York">Eastern Time (ET)</option>
|
||||
<option value="America/Chicago">Central Time (CT)</option>
|
||||
<option value="America/Denver">Mountain Time (MT)</option>
|
||||
<option value="America/Los_Angeles">Pacific Time (PT)</option>
|
||||
<option value="America/Phoenix">Arizona Time (MST)</option>
|
||||
<option value="America/Anchorage">Alaska Time (AKT)</option>
|
||||
<option value="Pacific/Honolulu">Hawaii Time (HST)</option>
|
||||
<option value="Europe/London">London (GMT/BST)</option>
|
||||
<option value="Europe/Paris">Paris (CET/CEST)</option>
|
||||
<option value="Asia/Tokyo">Tokyo (JST)</option>
|
||||
<option value="Australia/Sydney">Sydney (AEST/AEDT)</option>
|
||||
</select>
|
||||
{errors.timeZone && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.timeZone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" loading={loading} className="min-h-[44px]">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
63
frontend/src/features/onboarding/hooks/useOnboarding.ts
Normal file
63
frontend/src/features/onboarding/hooks/useOnboarding.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @ai-summary React Query hooks for onboarding flow
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { onboardingApi } from '../api/onboarding.api';
|
||||
import { OnboardingPreferences } from '../types/onboarding.types';
|
||||
|
||||
interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
status?: number;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const useOnboardingStatus = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['onboarding-status'],
|
||||
queryFn: onboardingApi.getStatus,
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSavePreferences = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: OnboardingPreferences) => onboardingApi.savePreferences(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
toast.success('Preferences saved successfully');
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to save preferences';
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCompleteOnboarding = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: onboardingApi.completeOnboarding,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to complete onboarding';
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
};
|
||||
28
frontend/src/features/onboarding/index.ts
Normal file
28
frontend/src/features/onboarding/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @ai-summary Public API exports for onboarding feature
|
||||
*/
|
||||
|
||||
// Pages
|
||||
export { OnboardingPage } from './pages/OnboardingPage';
|
||||
|
||||
// Mobile
|
||||
export { OnboardingMobileScreen } from './mobile/OnboardingMobileScreen';
|
||||
|
||||
// Components
|
||||
export { PreferencesStep } from './components/PreferencesStep';
|
||||
export { AddVehicleStep } from './components/AddVehicleStep';
|
||||
export { CompleteStep } from './components/CompleteStep';
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useOnboardingStatus,
|
||||
useSavePreferences,
|
||||
useCompleteOnboarding,
|
||||
} from './hooks/useOnboarding';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
OnboardingPreferences,
|
||||
OnboardingStatus,
|
||||
OnboardingStep,
|
||||
} from './types/onboarding.types';
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @ai-summary Mobile onboarding screen with multi-step wizard
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { useSavePreferences, useCompleteOnboarding } from '../hooks/useOnboarding';
|
||||
import { PreferencesStep } from '../components/PreferencesStep';
|
||||
import { AddVehicleStep } from '../components/AddVehicleStep';
|
||||
import { CompleteStep } from '../components/CompleteStep';
|
||||
import { OnboardingStep, OnboardingPreferences } from '../types/onboarding.types';
|
||||
import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
|
||||
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const OnboardingMobileScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<OnboardingStep>('preferences');
|
||||
const savePreferences = useSavePreferences();
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const [isAddingVehicle, setIsAddingVehicle] = useState(false);
|
||||
|
||||
const stepNumbers: Record<OnboardingStep, number> = {
|
||||
preferences: 1,
|
||||
vehicle: 2,
|
||||
complete: 3,
|
||||
};
|
||||
|
||||
const handleSavePreferences = async (data: OnboardingPreferences) => {
|
||||
try {
|
||||
await savePreferences.mutateAsync(data);
|
||||
setCurrentStep('vehicle');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVehicle = async (data: CreateVehicleRequest) => {
|
||||
setIsAddingVehicle(true);
|
||||
try {
|
||||
await vehiclesApi.create(data);
|
||||
toast.success('Vehicle added successfully');
|
||||
setCurrentStep('complete');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to add vehicle');
|
||||
} finally {
|
||||
setIsAddingVehicle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipVehicle = () => {
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
await completeOnboarding.mutateAsync();
|
||||
navigate('/vehicles');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === 'vehicle') {
|
||||
setCurrentStep('preferences');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center pt-4">
|
||||
<h1 className="text-2xl font-bold text-slate-800 mb-2">Welcome to MotoVault Pro</h1>
|
||||
<p className="text-slate-600 text-sm">Let's set up your account</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="flex items-center justify-between px-4">
|
||||
{(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
|
||||
<React.Fragment key={step}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm transition-all ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{stepNumbers[step]}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs mt-1 font-medium ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step === 'preferences' && 'Setup'}
|
||||
{step === 'vehicle' && 'Vehicle'}
|
||||
{step === 'complete' && 'Done'}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded transition-all ${
|
||||
stepNumbers[currentStep] > stepNumbers[step]
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<GlassCard padding="md">
|
||||
{currentStep === 'preferences' && (
|
||||
<PreferencesStep
|
||||
onNext={handleSavePreferences}
|
||||
loading={savePreferences.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'vehicle' && (
|
||||
<AddVehicleStep
|
||||
onNext={handleSkipVehicle}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
onBack={handleBack}
|
||||
loading={isAddingVehicle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'complete' && (
|
||||
<CompleteStep
|
||||
onComplete={handleComplete}
|
||||
loading={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingMobileScreen;
|
||||
147
frontend/src/features/onboarding/pages/OnboardingPage.tsx
Normal file
147
frontend/src/features/onboarding/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @ai-summary Desktop onboarding page with multi-step wizard
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSavePreferences, useCompleteOnboarding } from '../hooks/useOnboarding';
|
||||
import { PreferencesStep } from '../components/PreferencesStep';
|
||||
import { AddVehicleStep } from '../components/AddVehicleStep';
|
||||
import { CompleteStep } from '../components/CompleteStep';
|
||||
import { OnboardingStep, OnboardingPreferences } from '../types/onboarding.types';
|
||||
import { CreateVehicleRequest } from '../../vehicles/types/vehicles.types';
|
||||
import { vehiclesApi } from '../../vehicles/api/vehicles.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const OnboardingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<OnboardingStep>('preferences');
|
||||
const savePreferences = useSavePreferences();
|
||||
const completeOnboarding = useCompleteOnboarding();
|
||||
const [isAddingVehicle, setIsAddingVehicle] = useState(false);
|
||||
|
||||
const stepNumbers: Record<OnboardingStep, number> = {
|
||||
preferences: 1,
|
||||
vehicle: 2,
|
||||
complete: 3,
|
||||
};
|
||||
|
||||
const handleSavePreferences = async (data: OnboardingPreferences) => {
|
||||
try {
|
||||
await savePreferences.mutateAsync(data);
|
||||
setCurrentStep('vehicle');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVehicle = async (data: CreateVehicleRequest) => {
|
||||
setIsAddingVehicle(true);
|
||||
try {
|
||||
await vehiclesApi.create(data);
|
||||
toast.success('Vehicle added successfully');
|
||||
setCurrentStep('complete');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Failed to add vehicle');
|
||||
} finally {
|
||||
setIsAddingVehicle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipVehicle = () => {
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
await completeOnboarding.mutateAsync();
|
||||
navigate('/vehicles');
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === 'vehicle') {
|
||||
setCurrentStep('preferences');
|
||||
}
|
||||
};
|
||||
|
||||
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="w-full max-w-2xl">
|
||||
{/* Progress Indicator */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{(['preferences', 'vehicle', 'complete'] as OnboardingStep[]).map((step, index) => (
|
||||
<React.Fragment key={step}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-all ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{stepNumbers[step]}
|
||||
</div>
|
||||
<span
|
||||
className={`ml-2 text-sm font-medium hidden sm:inline ${
|
||||
stepNumbers[currentStep] >= stepNumbers[step]
|
||||
? 'text-primary-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{step === 'preferences' && 'Preferences'}
|
||||
{step === 'vehicle' && 'Add Vehicle'}
|
||||
{step === 'complete' && 'Complete'}
|
||||
</span>
|
||||
</div>
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded transition-all ${
|
||||
stepNumbers[currentStep] > stepNumbers[step]
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 text-center mt-4">
|
||||
Step {stepNumbers[currentStep]} of 3
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 p-6 md:p-8">
|
||||
{currentStep === 'preferences' && (
|
||||
<PreferencesStep
|
||||
onNext={handleSavePreferences}
|
||||
loading={savePreferences.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'vehicle' && (
|
||||
<AddVehicleStep
|
||||
onNext={handleSkipVehicle}
|
||||
onAddVehicle={handleAddVehicle}
|
||||
onBack={handleBack}
|
||||
loading={isAddingVehicle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'complete' && (
|
||||
<CompleteStep
|
||||
onComplete={handleComplete}
|
||||
loading={completeOnboarding.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
17
frontend/src/features/onboarding/types/onboarding.types.ts
Normal file
17
frontend/src/features/onboarding/types/onboarding.types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @ai-summary TypeScript types for onboarding feature
|
||||
*/
|
||||
|
||||
export interface OnboardingPreferences {
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
currencyCode: string;
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStatus {
|
||||
preferencesSet: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
onboardingCompletedAt: string | null;
|
||||
}
|
||||
|
||||
export type OnboardingStep = 'preferences' | 'vehicle' | 'complete';
|
||||
Reference in New Issue
Block a user