feat: delete users - not tested

This commit is contained in:
Eric Gullickson
2025-12-22 18:20:25 -06:00
parent 91b4534e76
commit 4897f0a52c
73 changed files with 4923 additions and 62 deletions

View 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;
},
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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);
},
});
};

View 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';

View File

@@ -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;

View 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;

View 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';