π― Module 7 Project: Complete User Registration System
Congratulations on completing Module 7! You've learned everything about forms in Reactβfrom basic form handling to advanced patterns like multi-step forms, file uploads, and complex validation. Now it's time to bring it all together. In this capstone project, you'll build a production-ready user registration system that demonstrates every skill you've learned. This isn't just an exerciseβit's a portfolio piece that showcases your ability to build real-world applications.
π― Project Objectives
By completing this project, you will demonstrate your ability to:
- Build a complete multi-step registration form with progress tracking
- Implement comprehensive validation using Zod schemas
- Integrate React Hook Form for efficient form state management
- Handle file uploads with preview and validation
- Create conditional fields based on user selections
- Implement auto-save functionality to localStorage
- Build accessible forms with proper ARIA attributes
- Design a responsive, mobile-friendly user interface
- Handle form submission and display success states
- Organize code in a maintainable, production-ready structure
Estimated Time: 4-6 hours (or spread over multiple sessions)
Difficulty: Intermediate to Advanced
π Project Guide
π Project Overview
You'll build a comprehensive user registration system with four main steps:
Account Info] --> B[Step 2:
Personal Details] B --> C[Step 3:
Profile Picture] C --> D{Complete
Profile?} D -->|Yes| E[Step 4:
Professional Info] D -->|No| F[Review & Submit] E --> F F --> G[Success!] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style G fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style D fill:#FFA726,stroke:#333,stroke-width:2px,color:#fff
What You'll Build
π± User Experience Flow
- Step 1 - Account Information:
- Email address with validation
- Password with strength requirements
- Password confirmation
- Username with availability check (simulated)
- Step 2 - Personal Details:
- First and last name
- Date of birth with age validation
- Phone number (optional)
- Address fields with country/state/city dropdowns
- Step 3 - Profile Picture:
- Drag-and-drop file upload
- Image preview
- File type and size validation
- Option to skip
- Step 4 - Professional Information (Conditional):
- Only shown if user opts in
- Job title and company
- Industry selection
- LinkedIn profile (optional)
- Review & Submit:
- Summary of all entered information
- Edit buttons to return to each step
- Final submission
Key Features
| Feature | Description | Skills Demonstrated |
|---|---|---|
| Multi-Step Navigation | 4-5 steps with back/next buttons | State management, component composition |
| Progress Tracking | Visual indicator showing current step | UI/UX design, conditional rendering |
| Zod Validation | Comprehensive schema validation per step | Schema design, error handling |
| File Upload | Drag-drop with preview and validation | File API, image handling |
| Conditional Steps | Professional info shown conditionally | Dynamic forms, conditional logic |
| Auto-Save | Save progress to localStorage | Side effects, data persistence |
| Cascading Dropdowns | Country β State β City selection | Field dependencies, API patterns |
| Accessibility | Full ARIA support, keyboard navigation | a11y best practices |
π Why This Project Matters
This project combines every major concept from Module 7:
- β Complex form handling from Lesson 7.1
- β React Hook Form integration from Lesson 7.2
- β Zod validation from Lesson 7.3
- β File uploads from Lesson 7.4
- β Advanced patterns from Lesson 7.5
When you complete this project, you'll have a portfolio-ready application that demonstrates professional-level form handling skills!
β¨ Features and Requirements
Let's break down the detailed requirements for each part of the registration system.
Functional Requirements
1. Step Navigation
- β Users can navigate forward by clicking "Next" button
- β Users can navigate backward by clicking "Back" button
- β Current step must validate before allowing forward navigation
- β Back navigation doesn't require validation
- β Users can jump to any previously completed step from progress indicator
- β Scroll to top when changing steps
2. Form Validation
- β Email must be valid format
- β Password must be at least 8 characters with uppercase, lowercase, and number
- β Password confirmation must match
- β Username must be 3-20 characters, alphanumeric with underscores
- β Date of birth must indicate user is at least 13 years old
- β Phone number must be valid format (if provided)
- β Profile picture must be image format, max 5MB
- β All required fields must be filled
3. File Upload
- β Support both click-to-browse and drag-and-drop
- β Show image preview after selection
- β Display file name and size
- β Allow removing selected file
- β Validate file type (JPEG, PNG, GIF, WebP)
- β Validate file size (max 5MB)
- β Allow skipping this step
4. Conditional Logic
- β Step 4 (Professional Info) only appears if user checks "Complete professional profile"
- β State dropdown populates based on selected country
- β City dropdown populates based on selected state
- β Progress indicator reflects total actual steps (4 or 5 depending on conditional)
5. Auto-Save
- β Save form data to localStorage after each step completion
- β Restore saved data when user returns
- β Show "Draft saved" indicator
- β Provide option to clear saved draft
- β Clear draft after successful submission
6. Review Step
- β Display all collected information in organized sections
- β Show profile picture preview if uploaded
- β Provide "Edit" button for each section to return to that step
- β Final "Submit" button to complete registration
β Non-Functional Requirements
- Responsive Design: Works perfectly on mobile, tablet, and desktop
- Accessibility: Keyboard navigation, screen reader support, proper ARIA labels
- Performance: Fast interactions, optimized re-renders
- User Experience: Clear error messages, visual feedback, loading states
- Code Quality: TypeScript types, organized structure, reusable components
Success Criteria
π― You'll Know You're Done When...
- β All steps can be completed without errors
- β Validation works correctly for all fields
- β File upload shows preview and validates properly
- β Conditional step appears/disappears as expected
- β Auto-save preserves data between page refreshes
- β Review step displays all information correctly
- β Form submits successfully and shows success message
- β All navigation (next, back, jump to step) works smoothly
- β Application is fully responsive on mobile
- β Accessibility features work (keyboard, screen reader)
ποΈ Project Structure
A well-organized project structure is crucial for maintainability. Here's the recommended folder structure for your registration system:
src/
βββ components/
β βββ RegistrationForm/
β β βββ RegistrationForm.tsx # Main container component
β β βββ ProgressIndicator.tsx # Progress bar/steps indicator
β β βββ SaveIndicator.tsx # Auto-save status indicator
β β βββ steps/
β β βββ AccountInfoStep.tsx # Step 1 component
β β βββ PersonalDetailsStep.tsx # Step 2 component
β β βββ ProfilePictureStep.tsx # Step 3 component
β β βββ ProfessionalInfoStep.tsx # Step 4 component (conditional)
β β βββ ReviewStep.tsx # Final review step
β β
β βββ FileUpload/
β β βββ FileUploadDropzone.tsx # Drag-drop file upload
β β βββ ImagePreview.tsx # Image preview component
β β
β βββ FormFields/
β βββ LocationSelector.tsx # Country/State/City cascading dropdowns
β βββ PasswordStrengthIndicator.tsx # Password strength meter
β
βββ types/
β βββ registration.ts # TypeScript interfaces and types
β
βββ schemas/
β βββ registrationSchemas.ts # Zod validation schemas
β
βββ hooks/
β βββ useAutoSave.ts # Auto-save custom hook
β βββ useLocationData.ts # Location data fetching hook
β
βββ utils/
β βββ validation.ts # Validation helper functions
β βββ formatters.ts # Data formatting utilities
β
βββ data/
β βββ locations.ts # Mock location data
β
βββ App.tsx # Main app component
π‘ Why This Structure?
- components/RegistrationForm: Groups all registration-related components together
- components/FileUpload: Reusable file upload components that could be used elsewhere
- components/FormFields: Reusable form field components
- types: Centralized TypeScript type definitions
- schemas: All Zod schemas in one place for easy maintenance
- hooks: Custom hooks for reusable logic
- utils: Pure utility functions
- data: Mock data and constants
This structure follows feature-based organization while keeping reusable components separate. It's scalable and maintainable!
Component Hierarchy
App
βββ RegistrationForm (main container)
βββ ProgressIndicator
βββ SaveIndicator
βββ [Current Step Component]
βββ AccountInfoStep
β βββ PasswordStrengthIndicator
βββ PersonalDetailsStep
β βββ LocationSelector
βββ ProfilePictureStep
β βββ FileUploadDropzone
β βββ ImagePreview
βββ ProfessionalInfoStep
βββ ReviewStep
β οΈ Important Notes
- Single Responsibility: Each component should do one thing well
- Reusability: Components like FileUploadDropzone can be reused in other projects
- Type Safety: All props should be properly typed with TypeScript interfaces
- Testing: This structure makes components easy to test in isolation
βοΈ Setup and Dependencies
Before we start coding, let's set up the project and install all necessary dependencies.
Step 1: Create React + TypeScript Project
# Create new Vite project with React and TypeScript
npm create vite@latest registration-system -- --template react-ts
# Navigate to project directory
cd registration-system
# Install dependencies
npm install
Step 2: Install Required Packages
# Form handling and validation
npm install react-hook-form @hookform/resolvers zod
# For TypeScript users (type definitions)
npm install -D @types/react @types/react-dom
Step 3: Project Configuration
Update your tsconfig.json to ensure strict type checking:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Installed Dependencies Overview
| Package | Version | Purpose |
|---|---|---|
react |
^18.2.0 | React library |
react-dom |
^18.2.0 | React DOM rendering |
typescript |
^5.0.0 | TypeScript compiler |
react-hook-form |
^7.45.0 | Efficient form state management |
zod |
^3.22.0 | Schema validation |
@hookform/resolvers |
^3.3.0 | Connect Zod with React Hook Form |
β Why These Dependencies?
- React Hook Form: Best-in-class form library with minimal re-renders
- Zod: Type-safe schema validation that integrates perfectly with TypeScript
- @hookform/resolvers: Official integration between RHF and Zod
- TypeScript: Type safety throughout the application
This is a minimal, production-ready stack with no unnecessary dependencies!
Development Server
# Start development server
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
π‘ Pro Tips
- Hot Module Replacement (HMR): Vite provides instant updates during development
- TypeScript Checking: Run
tsc --noEmitto check types without building - ESLint: Consider adding ESLint for code quality checks
- Git: Initialize git repository and commit your initial setup
π Phase 1: Types and Schemas
Before building components, let's define our TypeScript types and Zod validation schemas. This ensures type safety throughout the application and centralizes our validation logic.
Create Types File
Create src/types/registration.ts:
// src/types/registration.ts
export interface AccountInfo {
email: string;
password: string;
confirmPassword: string;
username: string;
}
export interface PersonalDetails {
firstName: string;
lastName: string;
dateOfBirth: string;
phone?: string;
country: string;
state: string;
city: string;
addressLine1: string;
addressLine2?: string;
zipCode: string;
}
export interface ProfilePicture {
file: File | null;
preview?: string;
}
export interface ProfessionalInfo {
completeProfessionalProfile: boolean;
jobTitle?: string;
company?: string;
industry?: string;
linkedInUrl?: string;
}
export interface RegistrationFormData {
accountInfo?: AccountInfo;
personalDetails?: PersonalDetails;
profilePicture?: ProfilePicture;
professionalInfo?: ProfessionalInfo;
}
export interface StepComponentProps {
onNext: (data: any) => void;
onBack?: () => void;
defaultValues?: any;
}
export interface LocationOption {
value: string;
label: string;
}
export interface CountryData {
[key: string]: {
[state: string]: string[];
};
}
β Type Design Principles
- Granular Types: Each step has its own interface
- Optional Fields: Use
?for non-required fields - Consistent Naming: Use clear, descriptive names
- Reusable Props: StepComponentProps can be used by all step components
Create Validation Schemas
Create src/schemas/registrationSchemas.ts:
// src/schemas/registrationSchemas.ts
import { z } from 'zod';
// Step 1: Account Information Schema
export const accountInfoSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must not exceed 20 characters')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores'
)
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword']
});
// Step 2: Personal Details Schema
export const personalDetailsSchema = z.object({
firstName: z
.string()
.min(2, 'First name must be at least 2 characters')
.max(50, 'First name must not exceed 50 characters'),
lastName: z
.string()
.min(2, 'Last name must be at least 2 characters')
.max(50, 'Last name must not exceed 50 characters'),
dateOfBirth: z
.string()
.min(1, 'Date of birth is required')
.refine((date) => {
const birthDate = new Date(date);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
return age - 1 >= 13;
}
return age >= 13;
}, 'You must be at least 13 years old'),
phone: z
.string()
.regex(/^\d{10}$/, 'Phone number must be 10 digits')
.optional()
.or(z.literal('')),
country: z.string().min(1, 'Country is required'),
state: z.string().min(1, 'State is required'),
city: z.string().min(1, 'City is required'),
addressLine1: z
.string()
.min(5, 'Address must be at least 5 characters')
.max(100, 'Address must not exceed 100 characters'),
addressLine2: z.string().max(100).optional().or(z.literal('')),
zipCode: z
.string()
.min(1, 'ZIP code is required')
.regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code format')
});
// Step 3: Profile Picture Schema
export const profilePictureSchema = z.object({
file: z
.custom<File>((file) => file instanceof File || file === null)
.refine(
(file) => {
if (!file) return true; // Optional
return file.size <= 5 * 1024 * 1024; // 5MB
},
'File size must be less than 5MB'
)
.refine(
(file) => {
if (!file) return true; // Optional
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
return validTypes.includes(file.type);
},
'File must be a valid image format (JPEG, PNG, GIF, WebP)'
)
.nullable(),
preview: z.string().optional()
});
// Step 4: Professional Information Schema
export const professionalInfoSchema = z.object({
completeProfessionalProfile: z.boolean(),
jobTitle: z.string().optional(),
company: z.string().optional(),
industry: z.string().optional(),
linkedInUrl: z
.string()
.url('Invalid URL format')
.optional()
.or(z.literal(''))
}).refine(
(data) => {
// If user opts to complete professional profile, these fields are required
if (data.completeProfessionalProfile) {
return !!data.jobTitle && !!data.company && !!data.industry;
}
return true;
},
{
message: 'Job title, company, and industry are required',
path: ['jobTitle']
}
);
// Export type inference
export type AccountInfoType = z.infer<typeof accountInfoSchema>;
export type PersonalDetailsType = z.infer<typeof personalDetailsSchema>;
export type ProfilePictureType = z.infer<typeof profilePictureSchema>;
export type ProfessionalInfoType = z.infer<typeof professionalInfoSchema>;
π Schema Highlights
- Password Validation: Multiple regex checks for complexity
- Age Validation: Custom refine function to check minimum age
- Phone Optional: Accepts empty string or valid 10-digit number
- File Validation: Checks both size and type
- Conditional Validation: Professional fields required only if user opts in
- Type Inference: Export TypeScript types from schemas
π‘ Advanced Zod Patterns
Notice these advanced Zod techniques:
.refine()for custom validation logic.optional().or(z.literal(''))to accept empty stringsz.custom<File>()for File object validation- Cross-field validation (password matching)
- Conditional schema validation (professional info)
π¨ Phase 2: Step Components (Part 1)
Now that we have our types and schemas, let's start building the step components. We'll create each step systematically, starting with the Account Information step.
Step 1: Account Information Component
Create src/components/RegistrationForm/steps/AccountInfoStep.tsx:
// src/components/RegistrationForm/steps/AccountInfoStep.tsx
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { accountInfoSchema, AccountInfoType } from '../../../schemas/registrationSchemas';
import { StepComponentProps } from '../../../types/registration';
const AccountInfoStep: React.FC<StepComponentProps> = ({
onNext,
defaultValues
}) => {
const {
register,
handleSubmit,
watch,
formState: { errors }
} = useForm<AccountInfoType>({
resolver: zodResolver(accountInfoSchema),
defaultValues
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const password = watch('password', '');
// Calculate password strength
const calculatePasswordStrength = (pwd: string): number => {
let strength = 0;
if (pwd.length >= 8) strength += 25;
if (/[A-Z]/.test(pwd)) strength += 25;
if (/[a-z]/.test(pwd)) strength += 25;
if (/[0-9]/.test(pwd)) strength += 25;
return strength;
};
const passwordStrength = calculatePasswordStrength(password);
const getStrengthColor = (strength: number): string => {
if (strength <= 25) return '#f44336';
if (strength <= 50) return '#ff9800';
if (strength <= 75) return '#ffc107';
return '#4CAF50';
};
const getStrengthLabel = (strength: number): string => {
if (strength <= 25) return 'Weak';
if (strength <= 50) return 'Fair';
if (strength <= 75) return 'Good';
return 'Strong';
};
const onSubmit = (data: AccountInfoType) => {
onNext(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="step-form">
<div className="step-header">
<h2>π Step 1: Account Information</h2>
<p>Create your account credentials</p>
</div>
{/* Email */}
<div className="form-group">
<label htmlFor="email">
Email Address <span className="required">*</span>
</label>
<input
id="email"
type="email"
{...register('email')}
className={errors.email ? 'error' : ''}
placeholder="you@example.com"
aria-invalid={errors.email ? 'true' : 'false'}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" className="error-message" role="alert">
{errors.email.message}
</span>
)}
</div>
{/* Username */}
<div className="form-group">
<label htmlFor="username">
Username <span className="required">*</span>
</label>
<input
id="username"
type="text"
{...register('username')}
className={errors.username ? 'error' : ''}
placeholder="johndoe"
aria-invalid={errors.username ? 'true' : 'false'}
aria-describedby={errors.username ? 'username-error' : undefined}
/>
{errors.username && (
<span id="username-error" className="error-message" role="alert">
{errors.username.message}
</span>
)}
<small className="field-hint">
3-20 characters, letters, numbers, and underscores only
</small>
</div>
{/* Password */}
<div className="form-group">
<label htmlFor="password">
Password <span className="required">*</span>
</label>
<div className="password-input-wrapper">
<input
id="password"
type={showPassword ? 'text' : 'password'}
{...register('password')}
className={errors.password ? 'error' : ''}
placeholder="Enter a strong password"
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error password-requirements' : 'password-requirements'}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="password-toggle"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? 'ποΈ' : 'ποΈβπ¨οΈ'}
</button>
</div>
{errors.password && (
<span id="password-error" className="error-message" role="alert">
{errors.password.message}
</span>
)}
{/* Password Strength Indicator */}
{password && (
<div className="password-strength">
<div className="strength-bar-container">
<div
className="strength-bar"
style={{
width: `${passwordStrength}%`,
backgroundColor: getStrengthColor(passwordStrength)
}}
/>
</div>
<span className="strength-label">
Strength: {getStrengthLabel(passwordStrength)}
</span>
</div>
)}
<small id="password-requirements" className="field-hint">
Must be at least 8 characters with uppercase, lowercase, and number
</small>
</div>
{/* Confirm Password */}
<div className="form-group">
<label htmlFor="confirmPassword">
Confirm Password <span className="required">*</span>
</label>
<div className="password-input-wrapper">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
{...register('confirmPassword')}
className={errors.confirmPassword ? 'error' : ''}
placeholder="Re-enter your password"
aria-invalid={errors.confirmPassword ? 'true' : 'false'}
aria-describedby={errors.confirmPassword ? 'confirm-password-error' : undefined}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="password-toggle"
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
>
{showConfirmPassword ? 'ποΈ' : 'ποΈβπ¨οΈ'}
</button>
</div>
{errors.confirmPassword && (
<span id="confirm-password-error" className="error-message" role="alert">
{errors.confirmPassword.message}
</span>
)}
</div>
{/* Form Actions */}
<div className="form-actions">
<button type="submit" className="btn-primary">
Next: Personal Details β
</button>
</div>
</form>
);
};
export default AccountInfoStep;
β Component Features
- Password Visibility Toggle: Eye icon to show/hide password
- Password Strength Meter: Visual indicator of password strength
- Real-time Validation: Using React Hook Form's built-in validation
- Accessibility: Proper ARIA attributes for screen readers
- Field Hints: Helpful text under inputs
- Error Messages: Clear, actionable error feedback
π‘ Key Patterns to Notice
watch('password')to monitor password changes for strength calculation- Separate state for password visibility toggles
- Helper functions for password strength calculation
- Conditional rendering of strength meter only when password exists
- ARIA attributes for accessibility (aria-invalid, aria-describedby)
Step 2: Personal Details Component
Create src/components/RegistrationForm/steps/PersonalDetailsStep.tsx:
// src/components/RegistrationForm/steps/PersonalDetailsStep.tsx
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { personalDetailsSchema, PersonalDetailsType } from '../../../schemas/registrationSchemas';
import { StepComponentProps } from '../../../types/registration';
import { locationData } from '../../../data/locations';
const PersonalDetailsStep: React.FC<StepComponentProps> = ({
onNext,
onBack,
defaultValues
}) => {
const {
register,
handleSubmit,
watch,
setValue,
resetField,
formState: { errors }
} = useForm<PersonalDetailsType>({
resolver: zodResolver(personalDetailsSchema),
defaultValues
});
const selectedCountry = watch('country');
const selectedState = watch('state');
const [availableStates, setAvailableStates] = useState<string[]>([]);
const [availableCities, setAvailableCities] = useState<string[]>([]);
// Update states when country changes
useEffect(() => {
if (selectedCountry && locationData[selectedCountry]) {
const states = Object.keys(locationData[selectedCountry]);
setAvailableStates(states);
// Reset state and city when country changes
resetField('state');
resetField('city');
setAvailableCities([]);
} else {
setAvailableStates([]);
setAvailableCities([]);
}
}, [selectedCountry, resetField]);
// Update cities when state changes
useEffect(() => {
if (selectedCountry && selectedState && locationData[selectedCountry]?.[selectedState]) {
const cities = locationData[selectedCountry][selectedState];
setAvailableCities(cities);
// Reset city when state changes
resetField('city');
} else {
setAvailableCities([]);
}
}, [selectedState, selectedCountry, resetField]);
const onSubmit = (data: PersonalDetailsType) => {
onNext(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="step-form">
<div className="step-header">
<h2>π€ Step 2: Personal Details</h2>
<p>Tell us about yourself</p>
</div>
{/* Name Fields */}
<div className="form-row">
<div className="form-group">
<label htmlFor="firstName">
First Name <span className="required">*</span>
</label>
<input
id="firstName"
type="text"
{...register('firstName')}
className={errors.firstName ? 'error' : ''}
placeholder="John"
aria-invalid={errors.firstName ? 'true' : 'false'}
/>
{errors.firstName && (
<span className="error-message" role="alert">
{errors.firstName.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">
Last Name <span className="required">*</span>
</label>
<input
id="lastName"
type="text"
{...register('lastName')}
className={errors.lastName ? 'error' : ''}
placeholder="Doe"
aria-invalid={errors.lastName ? 'true' : 'false'}
/>
{errors.lastName && (
<span className="error-message" role="alert">
{errors.lastName.message}
</span>
)}
</div>
</div>
{/* Date of Birth and Phone */}
<div className="form-row">
<div className="form-group">
<label htmlFor="dateOfBirth">
Date of Birth <span className="required">*</span>
</label>
<input
id="dateOfBirth"
type="date"
{...register('dateOfBirth')}
className={errors.dateOfBirth ? 'error' : ''}
aria-invalid={errors.dateOfBirth ? 'true' : 'false'}
/>
{errors.dateOfBirth && (
<span className="error-message" role="alert">
{errors.dateOfBirth.message}
</span>
)}
<small className="field-hint">You must be at least 13 years old</small>
</div>
<div className="form-group">
<label htmlFor="phone">Phone Number (Optional)</label>
<input
id="phone"
type="tel"
{...register('phone')}
className={errors.phone ? 'error' : ''}
placeholder="1234567890"
aria-invalid={errors.phone ? 'true' : 'false'}
/>
{errors.phone && (
<span className="error-message" role="alert">
{errors.phone.message}
</span>
)}
<small className="field-hint">10 digits, no spaces or dashes</small>
</div>
</div>
{/* Location Selection */}
<h3 className="section-title">π Location</h3>
<div className="form-group">
<label htmlFor="country">
Country <span className="required">*</span>
</label>
<select
id="country"
{...register('country')}
className={errors.country ? 'error' : ''}
aria-invalid={errors.country ? 'true' : 'false'}
>
<option value="">Select a country</option>
{Object.keys(locationData).map(country => (
<option key={country} value={country}>
{country}
</option>
))}
</select>
{errors.country && (
<span className="error-message" role="alert">
{errors.country.message}
</span>
)}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="state">
State/Province <span className="required">*</span>
</label>
<select
id="state"
{...register('state')}
className={errors.state ? 'error' : ''}
disabled={!selectedCountry || availableStates.length === 0}
aria-invalid={errors.state ? 'true' : 'false'}
>
<option value="">
{selectedCountry ? 'Select a state' : 'Select country first'}
</option>
{availableStates.map(state => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
{errors.state && (
<span className="error-message" role="alert">
{errors.state.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="city">
City <span className="required">*</span>
</label>
<select
id="city"
{...register('city')}
className={errors.city ? 'error' : ''}
disabled={!selectedState || availableCities.length === 0}
aria-invalid={errors.city ? 'true' : 'false'}
>
<option value="">
{selectedState ? 'Select a city' : 'Select state first'}
</option>
{availableCities.map(city => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{errors.city && (
<span className="error-message" role="alert">
{errors.city.message}
</span>
)}
</div>
</div>
{/* Address Fields */}
<div className="form-group">
<label htmlFor="addressLine1">
Address Line 1 <span className="required">*</span>
</label>
<input
id="addressLine1"
type="text"
{...register('addressLine1')}
className={errors.addressLine1 ? 'error' : ''}
placeholder="123 Main Street"
aria-invalid={errors.addressLine1 ? 'true' : 'false'}
/>
{errors.addressLine1 && (
<span className="error-message" role="alert">
{errors.addressLine1.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="addressLine2">Address Line 2 (Optional)</label>
<input
id="addressLine2"
type="text"
{...register('addressLine2')}
placeholder="Apartment, suite, etc."
/>
</div>
<div className="form-group">
<label htmlFor="zipCode">
ZIP Code <span className="required">*</span>
</label>
<input
id="zipCode"
type="text"
{...register('zipCode')}
className={errors.zipCode ? 'error' : ''}
placeholder="12345 or 12345-6789"
aria-invalid={errors.zipCode ? 'true' : 'false'}
/>
{errors.zipCode && (
<span className="error-message" role="alert">
{errors.zipCode.message}
</span>
)}
</div>
{/* Form Actions */}
<div className="form-actions">
<button type="button" onClick={onBack} className="btn-secondary">
β Back
</button>
<button type="submit" className="btn-primary">
Next: Profile Picture β
</button>
</div>
</form>
);
};
export default PersonalDetailsStep;
Create Location Data File
Create src/data/locations.ts:
// src/data/locations.ts
export const locationData: {
[country: string]: {
[state: string]: string[];
};
} = {
'United States': {
California: [
'Los Angeles',
'San Francisco',
'San Diego',
'San Jose',
'Sacramento'
],
Texas: ['Houston', 'Dallas', 'Austin', 'San Antonio', 'Fort Worth'],
'New York': [
'New York City',
'Buffalo',
'Rochester',
'Albany',
'Syracuse'
],
Florida: ['Miami', 'Orlando', 'Tampa', 'Jacksonville', 'Fort Lauderdale'],
Illinois: ['Chicago', 'Aurora', 'Naperville', 'Joliet', 'Rockford']
},
Canada: {
Ontario: ['Toronto', 'Ottawa', 'Mississauga', 'Hamilton', 'London'],
Quebec: ['Montreal', 'Quebec City', 'Laval', 'Gatineau', 'Longueuil'],
'British Columbia': [
'Vancouver',
'Victoria',
'Surrey',
'Burnaby',
'Richmond'
],
Alberta: ['Calgary', 'Edmonton', 'Red Deer', 'Lethbridge', 'St. Albert']
},
'United Kingdom': {
England: ['London', 'Manchester', 'Birmingham', 'Liverpool', 'Leeds'],
Scotland: ['Edinburgh', 'Glasgow', 'Aberdeen', 'Dundee', 'Inverness'],
Wales: ['Cardiff', 'Swansea', 'Newport', 'Wrexham', 'Barry'],
'Northern Ireland': [
'Belfast',
'Derry',
'Lisburn',
'Newtownabbey',
'Bangor'
]
}
};
β PersonalDetailsStep Features
- Cascading Dropdowns: Country β State β City dependency
- Field Reset: Automatically clears dependent fields
- Disabled States: Dropdowns disabled until parent selected
- Form Layout: Uses form-row for side-by-side fields
- Helper Text: Clear instructions for each field
- Back Button: Navigate to previous step
Step 3: Profile Picture Component
Create src/components/RegistrationForm/steps/ProfilePictureStep.tsx:
// src/components/RegistrationForm/steps/ProfilePictureStep.tsx
import React, { useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { profilePictureSchema, ProfilePictureType } from '../../../schemas/registrationSchemas';
import { StepComponentProps } from '../../../types/registration';
const ProfilePictureStep: React.FC<StepComponentProps> = ({
onNext,
onBack,
defaultValues
}) => {
const {
control,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm<ProfilePictureType>({
resolver: zodResolver(profilePictureSchema),
defaultValues: defaultValues || { file: null, preview: undefined }
});
const [dragActive, setDragActive] = useState(false);
const selectedFile = watch('file');
const preview = watch('preview');
// Create preview when file is selected
useEffect(() => {
if (selectedFile && selectedFile instanceof File) {
const objectUrl = URL.createObjectURL(selectedFile);
setValue('preview', objectUrl);
// Cleanup
return () => URL.revokeObjectURL(objectUrl);
}
}, [selectedFile, setValue]);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
setValue('file', file, { shouldValidate: true });
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0];
setValue('file', file, { shouldValidate: true });
}
};
const removeFile = () => {
setValue('file', null);
setValue('preview', undefined);
};
const skipStep = () => {
setValue('file', null);
setValue('preview', undefined);
onNext({ file: null, preview: undefined });
};
const onSubmit = (data: ProfilePictureType) => {
onNext(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="step-form">
<div className="step-header">
<h2>π· Step 3: Profile Picture</h2>
<p>Upload a photo to personalize your profile (optional)</p>
</div>
<Controller
name="file"
control={control}
render={({ field }) => (
<div className="file-upload-section">
{!selectedFile ? (
<div
className={`dropzone ${dragActive ? 'drag-active' : ''} ${
errors.file ? 'error' : ''
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => document.getElementById('fileInput')?.click()}
>
<input
id="fileInput"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div className="dropzone-content">
<div className="upload-icon">π</div>
{dragActive ? (
<p className="upload-text">Drop your image here</p>
) : (
<>
<p className="upload-text">
Drag and drop your image here
</p>
<p className="upload-subtext">or click to browse</p>
<p className="upload-hint">
JPEG, PNG, GIF, or WebP β’ Max 5MB
</p>
</>
)}
</div>
</div>
) : (
<div className="file-preview">
{preview && (
<div className="preview-image-container">
<img
src={preview}
alt="Profile preview"
className="preview-image"
/>
</div>
)}
<div className="file-info">
<p className="file-name">{selectedFile.name}</p>
<p className="file-size">
{(selectedFile.size / 1024).toFixed(2)} KB
</p>
</div>
<button
type="button"
onClick={removeFile}
className="btn-remove"
>
Remove Image
</button>
</div>
)}
{errors.file && (
<span className="error-message" role="alert">
{errors.file.message}
</span>
)}
</div>
)}
/>
{/* Form Actions */}
<div className="form-actions">
<button type="button" onClick={onBack} className="btn-secondary">
β Back
</button>
<button
type="button"
onClick={skipStep}
className="btn-secondary-outline"
>
Skip This Step
</button>
<button type="submit" className="btn-primary">
Next: Professional Info β
</button>
</div>
</form>
);
};
export default ProfilePictureStep;
π‘ ProfilePictureStep Highlights
- Controller: Using React Hook Form's Controller for custom file input
- Drag-Drop: Full drag-and-drop support with visual feedback
- Image Preview: Shows preview using Object URL
- Cleanup: Properly revokes object URLs
- Skip Option: Users can skip this optional step
- File Info: Displays file name and size
Step 4: Professional Info Component (Conditional)
Create src/components/RegistrationForm/steps/ProfessionalInfoStep.tsx:
// src/components/RegistrationForm/steps/ProfessionalInfoStep.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { professionalInfoSchema, ProfessionalInfoType } from '../../../schemas/registrationSchemas';
import { StepComponentProps } from '../../../types/registration';
const ProfessionalInfoStep: React.FC<StepComponentProps> = ({
onNext,
onBack,
defaultValues
}) => {
const {
register,
handleSubmit,
watch,
formState: { errors }
} = useForm<ProfessionalInfoType>({
resolver: zodResolver(professionalInfoSchema),
defaultValues: defaultValues || { completeProfessionalProfile: false }
});
const completeProfessionalProfile = watch('completeProfessionalProfile');
const onSubmit = (data: ProfessionalInfoType) => {
onNext(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="step-form">
<div className="step-header">
<h2>πΌ Step 4: Professional Information</h2>
<p>Help others understand your professional background</p>
</div>
{/* Opt-in Checkbox */}
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
{...register('completeProfessionalProfile')}
/>
<span>I want to complete my professional profile</span>
</label>
<small className="field-hint">
This will help others find and connect with you professionally
</small>
</div>
{/* Conditional Professional Fields */}
{completeProfessionalProfile && (
<div className="conditional-section">
<h3 className="section-title">Professional Details</h3>
<div className="form-group">
<label htmlFor="jobTitle">
Job Title <span className="required">*</span>
</label>
<input
id="jobTitle"
type="text"
{...register('jobTitle')}
className={errors.jobTitle ? 'error' : ''}
placeholder="Software Engineer"
aria-invalid={errors.jobTitle ? 'true' : 'false'}
/>
{errors.jobTitle && (
<span className="error-message" role="alert">
{errors.jobTitle.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="company">
Company <span className="required">*</span>
</label>
<input
id="company"
type="text"
{...register('company')}
className={errors.company ? 'error' : ''}
placeholder="Acme Corporation"
aria-invalid={errors.company ? 'true' : 'false'}
/>
{errors.company && (
<span className="error-message" role="alert">
{errors.company.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="industry">
Industry <span className="required">*</span>
</label>
<select
id="industry"
{...register('industry')}
className={errors.industry ? 'error' : ''}
aria-invalid={errors.industry ? 'true' : 'false'}
>
<option value="">Select an industry</option>
<option value="Technology">Technology</option>
<option value="Healthcare">Healthcare</option>
<option value="Finance">Finance</option>
<option value="Education">Education</option>
<option value="Manufacturing">Manufacturing</option>
<option value="Retail">Retail</option>
<option value="Consulting">Consulting</option>
<option value="Other">Other</option>
</select>
{errors.industry && (
<span className="error-message" role="alert">
{errors.industry.message}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="linkedInUrl">LinkedIn Profile (Optional)</label>
<input
id="linkedInUrl"
type="url"
{...register('linkedInUrl')}
className={errors.linkedInUrl ? 'error' : ''}
placeholder="https://linkedin.com/in/yourprofile"
aria-invalid={errors.linkedInUrl ? 'true' : 'false'}
/>
{errors.linkedInUrl && (
<span className="error-message" role="alert">
{errors.linkedInUrl.message}
</span>
)}
</div>
</div>
)}
{/* Form Actions */}
<div className="form-actions">
<button type="button" onClick={onBack} className="btn-secondary">
β Back
</button>
<button type="submit" className="btn-primary">
Next: Review & Submit β
</button>
</div>
</form>
);
};
export default ProfessionalInfoStep;
β Conditional Step Pattern
- Opt-in Checkbox: User decides whether to complete this section
- Conditional Rendering: Fields only appear if checkbox is checked
- Conditional Validation: Required fields only when section is active
- Visual Grouping: Conditional section has distinct styling
ποΈ Phase 3: Main Container Component
Now we'll create the main RegistrationForm container that manages all steps, navigation, and state.
Create Main Registration Form Component
Create src/components/RegistrationForm/RegistrationForm.tsx:
// src/components/RegistrationForm/RegistrationForm.tsx
import React, { useState, useEffect } from 'react';
import { RegistrationFormData } from '../../types/registration';
import AccountInfoStep from './steps/AccountInfoStep';
import PersonalDetailsStep from './steps/PersonalDetailsStep';
import ProfilePictureStep from './steps/ProfilePictureStep';
import ProfessionalInfoStep from './steps/ProfessionalInfoStep';
import ReviewStep from './steps/ReviewStep';
import ProgressIndicator from './ProgressIndicator';
import SaveIndicator from './SaveIndicator';
const STORAGE_KEY = 'registration-draft';
const RegistrationForm: React.FC = () => {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<RegistrationFormData>({});
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const [lastSaved, setLastSaved] = useState<Date | null>(null);
// Load saved draft on mount
useEffect(() => {
const savedDraft = localStorage.getItem(STORAGE_KEY);
if (savedDraft) {
try {
const parsed = JSON.parse(savedDraft);
setFormData(parsed.formData || {});
setCurrentStep(parsed.currentStep || 1);
setCompletedSteps(new Set(parsed.completedSteps || []));
setLastSaved(new Date(parsed.timestamp));
} catch (error) {
console.error('Failed to load draft:', error);
}
}
}, []);
// Auto-save to localStorage
useEffect(() => {
if (Object.keys(formData).length > 0) {
const draftData = {
formData,
currentStep,
completedSteps: Array.from(completedSteps),
timestamp: new Date().toISOString()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(draftData));
setLastSaved(new Date());
}
}, [formData, currentStep, completedSteps]);
// Determine if professional step should be shown
const shouldShowProfessionalStep =
formData.professionalInfo?.completeProfessionalProfile ?? false;
// Calculate total steps
const getTotalSteps = () => {
return shouldShowProfessionalStep ? 5 : 4;
};
// Get actual step number (accounting for conditional step)
const getActualStepNumber = (step: number): number => {
if (!shouldShowProfessionalStep && step >= 4) {
return step + 1; // Skip professional step
}
return step;
};
const handleStepComplete = (stepData: any) => {
// Update form data
setFormData(prev => ({
...prev,
...stepData
}));
// Mark current step as completed
setCompletedSteps(prev => new Set([...prev, currentStep]));
// Move to next step
if (currentStep < getTotalSteps()) {
setCurrentStep(prev => prev + 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(prev => prev - 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleGoToStep = (step: number) => {
if (completedSteps.has(step) || step === currentStep) {
setCurrentStep(step);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleSubmit = async () => {
console.log('Submitting registration:', formData);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Clear saved draft
localStorage.removeItem(STORAGE_KEY);
// Show success (in real app, navigate to success page)
alert('Registration successful! π');
};
const clearDraft = () => {
if (window.confirm('Are you sure you want to clear your saved progress?')) {
localStorage.removeItem(STORAGE_KEY);
setFormData({});
setCurrentStep(1);
setCompletedSteps(new Set());
setLastSaved(null);
}
};
return (
<div className="registration-container">
<div className="registration-header">
<h1>Create Your Account</h1>
<p>Join thousands of users already on our platform</p>
</div>
<SaveIndicator lastSaved={lastSaved} onClear={clearDraft} />
<ProgressIndicator
currentStep={currentStep}
totalSteps={getTotalSteps()}
completedSteps={completedSteps}
onStepClick={handleGoToStep}
/>
<div className="step-container">
{currentStep === 1 && (
<AccountInfoStep
onNext={handleStepComplete}
defaultValues={formData.accountInfo}
/>
)}
{currentStep === 2 && (
<PersonalDetailsStep
onNext={handleStepComplete}
onBack={handleBack}
defaultValues={formData.personalDetails}
/>
)}
{currentStep === 3 && (
<ProfilePictureStep
onNext={handleStepComplete}
onBack={handleBack}
defaultValues={formData.profilePicture}
/>
)}
{currentStep === 4 && (
<ProfessionalInfoStep
onNext={handleStepComplete}
onBack={handleBack}
defaultValues={formData.professionalInfo}
/>
)}
{currentStep === getTotalSteps() && (
<ReviewStep
formData={formData}
onBack={handleBack}
onSubmit={handleSubmit}
onEdit={handleGoToStep}
/>
)}
</div>
</div>
);
};
export default RegistrationForm;
π― Main Container Responsibilities
- State Management: Manages all form data and navigation state
- Auto-Save: Automatically saves to localStorage
- Draft Recovery: Loads saved draft on mount
- Step Calculation: Dynamically calculates total steps
- Navigation: Handles next, back, and jump-to-step
- Scroll Management: Scrolls to top on step change
π Phase 4: Progress Indicator
A well-designed progress indicator helps users understand where they are in the registration process and how much more they need to complete. Let's build an interactive progress bar with step navigation.
Progress Indicator Component
Create src/components/RegistrationForm/ProgressIndicator.tsx:
// src/components/RegistrationForm/ProgressIndicator.tsx
import React from 'react';
interface ProgressIndicatorProps {
currentStep: number;
totalSteps: number;
completedSteps: Set<number>;
onStepClick?: (step: number) => void;
}
const ProgressIndicator: React.FC<ProgressIndicatorProps> = ({
currentStep,
totalSteps,
completedSteps,
onStepClick
}) => {
const stepLabels = [
'Account Info',
'Personal Details',
'Profile Picture',
'Professional Info',
'Review'
];
// Determine which labels to show based on total steps
const getStepLabel = (stepNumber: number): string => {
if (totalSteps === 4) {
// Skip "Professional Info" step
if (stepNumber === 4) return 'Review';
return stepLabels[stepNumber - 1];
}
return stepLabels[stepNumber - 1];
};
const isStepClickable = (stepNumber: number): boolean => {
// Can click on completed steps or current step
return completedSteps.has(stepNumber) || stepNumber === currentStep;
};
const getStepStatus = (stepNumber: number): 'completed' | 'current' | 'pending' => {
if (completedSteps.has(stepNumber)) return 'completed';
if (stepNumber === currentStep) return 'current';
return 'pending';
};
const handleStepClick = (stepNumber: number) => {
if (isStepClickable(stepNumber) && onStepClick) {
onStepClick(stepNumber);
}
};
return (
<div className="progress-indicator" role="navigation" aria-label="Registration progress">
{/* Progress Bar */}
<div className="progress-bar-wrapper">
<div
className="progress-bar-fill"
style={{ width: `${((currentStep - 1) / (totalSteps - 1)) * 100}%` }}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-label={`Step ${currentStep} of ${totalSteps}`}
/>
</div>
{/* Step Indicators */}
<div className="steps-container">
{Array.from({ length: totalSteps }, (_, index) => {
const stepNumber = index + 1;
const status = getStepStatus(stepNumber);
const clickable = isStepClickable(stepNumber);
return (
<div key={stepNumber} className="step-wrapper">
<button
type="button"
className={`step-indicator ${status} ${clickable ? 'clickable' : ''}`}
onClick={() => handleStepClick(stepNumber)}
disabled={!clickable}
aria-label={`Step ${stepNumber}: ${getStepLabel(stepNumber)}`}
aria-current={status === 'current' ? 'step' : undefined}
>
{status === 'completed' ? (
<span className="step-checkmark">β</span>
) : (
<span className="step-number">{stepNumber}</span>
)}
</button>
<span className={`step-label ${status}`}>
{getStepLabel(stepNumber)}
</span>
</div>
);
})}
</div>
</div>
);
};
export default ProgressIndicator;
β Progress Indicator Features
- Visual Progress Bar: Filled bar shows percentage completion
- Step Indicators: Numbered circles for each step
- Status Visualization: Different styles for completed, current, and pending steps
- Interactive Navigation: Click completed steps to return to them
- Dynamic Steps: Adapts labels based on whether conditional step is shown
- Accessibility: Proper ARIA attributes and keyboard support
CSS Styles for Progress Indicator
Add these styles to your CSS file:
/* Progress Indicator Styles */
.progress-indicator {
margin: 2rem 0;
padding: 1.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.progress-bar-wrapper {
position: relative;
height: 4px;
background: #e0e0e0;
border-radius: 2px;
margin-bottom: 2rem;
overflow: hidden;
}
.progress-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
border-radius: 2px;
}
.steps-container {
display: flex;
justify-content: space-between;
position: relative;
}
.step-wrapper {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.step-indicator {
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid #e0e0e0;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1rem;
color: #999;
transition: all 0.3s ease;
cursor: default;
position: relative;
z-index: 2;
}
.step-indicator.clickable {
cursor: pointer;
}
.step-indicator.clickable:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.step-indicator.completed {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
color: white;
}
.step-indicator.current {
border-color: #667eea;
background: white;
color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.2);
}
.step-checkmark {
font-size: 1.2rem;
}
.step-label {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #666;
text-align: center;
transition: color 0.3s ease;
}
.step-label.completed {
color: #667eea;
font-weight: 500;
}
.step-label.current {
color: #667eea;
font-weight: 600;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.step-indicator {
width: 32px;
height: 32px;
font-size: 0.9rem;
}
.step-label {
font-size: 0.75rem;
max-width: 80px;
word-wrap: break-word;
}
}
π‘ Design Decisions
- Visual Hierarchy: Larger, bolder current step draws attention
- Color Coding: Purple gradient for completed, gray for pending
- Checkmarks: Visual confirmation of completed steps
- Hover Effects: Scale and shadow on clickable steps
- Mobile-Friendly: Smaller circles and text on mobile devices
- Smooth Transitions: All state changes are animated
Save Indicator Component
Create src/components/RegistrationForm/SaveIndicator.tsx to show auto-save status:
// src/components/RegistrationForm/SaveIndicator.tsx
import React from 'react';
interface SaveIndicatorProps {
lastSaved: Date | null;
onClear?: () => void;
}
const SaveIndicator: React.FC<SaveIndicatorProps> = ({
lastSaved,
onClear
}) => {
const formatLastSaved = (date: Date): string => {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return 'just now';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
} else {
return date.toLocaleDateString();
}
};
if (!lastSaved) {
return null;
}
return (
<div className="save-indicator" role="status" aria-live="polite">
<div className="save-info">
<span className="save-icon">πΎ</span>
<span className="save-text">
Draft saved {formatLastSaved(lastSaved)}
</span>
</div>
{onClear && (
<button
type="button"
onClick={onClear}
className="clear-draft-btn"
aria-label="Clear saved draft"
>
Clear Draft
</button>
)}
</div>
);
};
export default SaveIndicator;
/* Save Indicator Styles */
.save-indicator {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
margin-bottom: 1rem;
background: #e8f5e9;
border-left: 4px solid #4CAF50;
border-radius: 4px;
animation: fadeIn 0.3s ease;
}
.save-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.save-icon {
font-size: 1.2rem;
}
.save-text {
color: #2e7d32;
font-size: 0.95rem;
font-weight: 500;
}
.clear-draft-btn {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.clear-draft-btn:hover {
background: #f5f5f5;
border-color: #999;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
π¨ UI/UX Best Practices
- Visual Feedback: Always show users where they are in the process
- Time Formatting: Relative time ("2 minutes ago") is more user-friendly
- Non-Intrusive: Save indicator doesn't block content
- Clear Actions: "Clear Draft" button is clearly labeled
- Color Psychology: Green for saved, purple for progress
π Phase 5: File Upload Integration
Now let's implement the profile picture upload functionality. We'll create a reusable file upload component with drag-and-drop, preview, and validation.
File Upload Dropzone Component
Create src/components/FileUpload/FileUploadDropzone.tsx:
// src/components/FileUpload/FileUploadDropzone.tsx
import React, { useState, useRef, DragEvent, ChangeEvent } from 'react';
interface FileUploadDropzoneProps {
onFileSelect: (file: File) => void;
onFileRemove: () => void;
currentFile: File | null;
accept?: string;
maxSize?: number; // in bytes
preview?: string;
}
const FileUploadDropzone: React.FC<FileUploadDropzoneProps> = ({
onFileSelect,
onFileRemove,
currentFile,
accept = 'image/jpeg,image/png,image/gif,image/webp',
maxSize = 5 * 1024 * 1024, // 5MB default
preview
}) => {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): string | null => {
// Check file type
const acceptedTypes = accept.split(',').map(type => type.trim());
if (!acceptedTypes.includes(file.type)) {
return `Invalid file type. Accepted types: ${accept}`;
}
// Check file size
if (file.size > maxSize) {
const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1);
return `File size must be less than ${maxSizeMB}MB`;
}
return null;
};
const handleFile = (file: File) => {
setError(null);
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
onFileSelect(file);
};
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFile(files[0]);
}
};
const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFile(files[0]);
}
};
const handleBrowseClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setError(null);
onFileRemove();
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="file-upload-container">
{!currentFile ? (
<div
className={`dropzone ${isDragging ? 'dragging' : ''} ${error ? 'error' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
role="button"
tabIndex={0}
aria-label="File upload area"
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileInputChange}
style={{ display: 'none' }}
aria-label="File input"
/>
<div className="dropzone-content">
<div className="upload-icon">π€</div>
<p className="dropzone-text">
Drag and drop your file here, or{' '}
<button
type="button"
onClick={handleBrowseClick}
className="browse-link"
>
browse
</button>
</p>
<p className="dropzone-hint">
Accepted: JPEG, PNG, GIF, WebP (max {(maxSize / (1024 * 1024)).toFixed(1)}MB)
</p>
</div>
</div>
) : (
<div className="file-preview-container">
{preview && (
<div className="image-preview">
<img src={preview} alt="Preview" />
</div>
)}
<div className="file-info">
<div className="file-details">
<span className="file-icon">π</span>
<div className="file-text">
<p className="file-name">{currentFile.name}</p>
<p className="file-size">{formatFileSize(currentFile.size)}</p>
</div>
</div>
<button
type="button"
onClick={handleRemove}
className="remove-file-btn"
aria-label="Remove file"
>
β
</button>
</div>
</div>
)}
{error && (
<div className="upload-error" role="alert">
{error}
</div>
)}
</div>
);
};
export default FileUploadDropzone;
β File Upload Features
- Drag and Drop: Intuitive drag-and-drop interface
- Click to Browse: Alternative file selection method
- File Validation: Type and size validation before upload
- Image Preview: Shows thumbnail of uploaded image
- File Information: Displays filename and size
- Remove Functionality: Easy way to remove and re-select
- Error Handling: Clear error messages for validation failures
- Accessibility: Keyboard navigation and screen reader support
CSS Styles for File Upload
/* File Upload Styles */
.file-upload-container {
width: 100%;
}
.dropzone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 3rem 2rem;
text-align: center;
background: #fafafa;
transition: all 0.3s ease;
cursor: pointer;
}
.dropzone:hover {
border-color: #667eea;
background: #f5f7ff;
}
.dropzone.dragging {
border-color: #667eea;
background: #e8eeff;
transform: scale(1.02);
}
.dropzone.error {
border-color: #f44336;
background: #ffebee;
}
.dropzone-content {
pointer-events: none;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.dropzone-text {
font-size: 1rem;
color: #333;
margin-bottom: 0.5rem;
}
.browse-link {
color: #667eea;
font-weight: 600;
background: none;
border: none;
cursor: pointer;
pointer-events: auto;
text-decoration: underline;
}
.browse-link:hover {
color: #5568d3;
}
.dropzone-hint {
font-size: 0.85rem;
color: #666;
}
.file-preview-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.image-preview {
width: 100%;
max-width: 300px;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.image-preview img {
width: 100%;
height: auto;
display: block;
}
.file-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f5f5f5;
border-radius: 8px;
}
.file-details {
display: flex;
align-items: center;
gap: 1rem;
}
.file-icon {
font-size: 2rem;
}
.file-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.file-name {
font-weight: 500;
color: #333;
margin: 0;
}
.file-size {
font-size: 0.85rem;
color: #666;
margin: 0;
}
.remove-file-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: #f44336;
color: white;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.remove-file-btn:hover {
background: #d32f2f;
transform: scale(1.1);
}
.upload-error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #ffebee;
color: #c62828;
border-left: 4px solid #f44336;
border-radius: 4px;
font-size: 0.9rem;
}
Profile Picture Step Implementation
Now let's use the dropzone in the Profile Picture step:
// src/components/RegistrationForm/steps/ProfilePictureStep.tsx
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { profilePictureSchema, ProfilePictureType } from '../../../schemas/registrationSchemas';
import { StepComponentProps } from '../../../types/registration';
import FileUploadDropzone from '../../FileUpload/FileUploadDropzone';
const ProfilePictureStep: React.FC<StepComponentProps> = ({
onNext,
onBack,
defaultValues
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(
defaultValues?.file || null
);
const [preview, setPreview] = useState<string | undefined>(
defaultValues?.preview
);
const {
handleSubmit,
setValue,
setError,
clearErrors,
formState: { errors }
} = useForm<ProfilePictureType>({
resolver: zodResolver(profilePictureSchema),
defaultValues: {
file: defaultValues?.file || null,
preview: defaultValues?.preview
}
});
useEffect(() => {
// Clean up preview URL on unmount
return () => {
if (preview && preview.startsWith('blob:')) {
URL.revokeObjectURL(preview);
}
};
}, [preview]);
const handleFileSelect = (file: File) => {
setSelectedFile(file);
setValue('file', file);
clearErrors('file');
// Create preview URL
const previewUrl = URL.createObjectURL(file);
setPreview(previewUrl);
setValue('preview', previewUrl);
};
const handleFileRemove = () => {
if (preview && preview.startsWith('blob:')) {
URL.revokeObjectURL(preview);
}
setSelectedFile(null);
setPreview(undefined);
setValue('file', null);
setValue('preview', undefined);
};
const handleSkip = () => {
onNext({
file: null,
preview: undefined
});
};
const onSubmit = (data: ProfilePictureType) => {
onNext(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="step-form">
<div className="step-header">
<h2>πΈ Step 3: Profile Picture</h2>
<p>Upload a profile picture (optional)</p>
</div>
<FileUploadDropzone
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
currentFile={selectedFile}
preview={preview}
accept="image/jpeg,image/png,image/gif,image/webp"
maxSize={5 * 1024 * 1024}
/>
{errors.file && (
<div className="error-message" role="alert">
{errors.file.message}
</div>
)}
<div className="card" style="background: #e3f2fd; border-left: 4px solid #2196F3; margin-top: 2rem;">
<h4>π‘ Profile Picture Tips</h4>
<ul>
<li>Use a clear, high-quality photo</li>
<li>Face should be clearly visible</li>
<li>Avoid filters or heavy editing</li>
<li>Professional headshots work best</li>
<li>Square images look best (1:1 aspect ratio)</li>
</ul>
</div>
<div className="form-actions">
<button type="button" onClick={onBack} className="btn-secondary">
β Back
</button>
<div className="button-group">
<button type="button" onClick={handleSkip} className="btn-text">
Skip for now
</button>
<button type="submit" className="btn-primary">
Next: Professional Info β
</button>
</div>
</div>
</form>
);
};
export default ProfilePictureStep;
π‘ Implementation Highlights
- Preview URL Management: Creates and cleans up blob URLs properly
- Form Integration: Properly integrates file upload with React Hook Form
- Skip Option: Allows users to continue without uploading
- Helpful Tips: Guides users on best practices for profile pictures
- Error Display: Shows validation errors from Zod schema
- Memory Management: Cleans up blob URLs to prevent memory leaks
πΎ Phase 6: Auto-Save Functionality
Auto-save is a crucial feature that prevents data loss. Let's implement automatic saving to localStorage after each step completion, with the ability to restore saved progress.
Auto-Save Custom Hook
Create src/hooks/useAutoSave.ts:
// src/hooks/useAutoSave.ts
import { useEffect, useState } from 'react';
interface UseAutoSaveOptions<T> {
key: string;
data: T;
enabled?: boolean;
debounceMs?: number;
}
interface UseAutoSaveReturn<T> {
save: () => void;
load: () => T | null;
clear: () => void;
lastSaved: Date | null;
}
export function useAutoSave<T>({
key,
data,
enabled = true,
debounceMs = 1000
}: UseAutoSaveOptions<T>): UseAutoSaveReturn<T> {
const [lastSaved, setLastSaved] = useState<Date | null>(null);
// Save data to localStorage
const save = () => {
try {
localStorage.setItem(key, JSON.stringify(data));
setLastSaved(new Date());
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
};
// Load data from localStorage
const load = (): T | null => {
try {
const saved = localStorage.getItem(key);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load from localStorage:', error);
}
return null;
};
// Clear saved data
const clear = () => {
try {
localStorage.removeItem(key);
setLastSaved(null);
} catch (error) {
console.error('Failed to clear localStorage:', error);
}
};
// Auto-save effect with debouncing
useEffect(() => {
if (!enabled) return;
const timeoutId = setTimeout(() => {
save();
}, debounceMs);
return () => clearTimeout(timeoutId);
}, [data, enabled, debounceMs]);
return { save, load, clear, lastSaved };
}
β Auto-Save Hook Features
- Automatic Saving: Saves data automatically after changes
- Debouncing: Prevents excessive saves during rapid changes
- Error Handling: Gracefully handles localStorage errors
- Last Saved Tracking: Tracks when data was last saved
- Manual Control: Provides save, load, and clear functions
- Type Safety: Fully typed with TypeScript generics
Integrating Auto-Save in Main Container
Let's enhance the RegistrationForm component with auto-save:
// Add to RegistrationForm.tsx
import { useAutoSave } from '../../hooks/useAutoSave';
// Inside component:
const STORAGE_KEY = 'registration-draft';
// Auto-save hook
const { lastSaved, clear: clearAutoSave } = useAutoSave({
key: STORAGE_KEY,
data: formData,
enabled: true,
debounceMs: 500 // Save 500ms after last change
});
// Load saved draft on mount
useEffect(() => {
const savedData = localStorage.getItem(STORAGE_KEY);
if (savedData) {
try {
const parsed = JSON.parse(savedData);
const hasData = Object.keys(parsed).length > 0;
if (hasData) {
const shouldRestore = window.confirm(
'We found a saved draft. Would you like to continue where you left off?'
);
if (shouldRestore) {
setFormData(parsed);
// Determine which step to start on
const completedSteps = new Set<number>();
let lastCompletedStep = 0;
if (parsed.accountInfo) {
completedSteps.add(1);
lastCompletedStep = 1;
}
if (parsed.personalDetails) {
completedSteps.add(2);
lastCompletedStep = 2;
}
if (parsed.profilePicture?.file) {
completedSteps.add(3);
lastCompletedStep = 3;
}
if (parsed.professionalInfo) {
completedSteps.add(4);
lastCompletedStep = 4;
}
setCompletedSteps(completedSteps);
setCurrentStep(lastCompletedStep + 1);
} else {
clearAutoSave();
}
}
} catch (error) {
console.error('Failed to restore draft:', error);
}
}
}, []);
// Clear draft after successful submission
const handleSubmit = async () => {
console.log('Submitting registration:', formData);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Clear saved draft
clearAutoSave();
// Show success (in real app, navigate to success page)
alert('Registration successful! π');
};
// Manual clear with confirmation
const clearDraft = () => {
if (window.confirm('Are you sure you want to clear your saved progress?')) {
clearAutoSave();
setFormData({});
setCurrentStep(1);
setCompletedSteps(new Set());
}
};
π‘ Auto-Save Best Practices
- User Consent: Ask before restoring saved data
- Smart Resume: Calculate which step to resume from
- Clear After Submit: Remove draft after successful submission
- Confirmation: Confirm before clearing saved progress
- Error Recovery: Handle JSON parse errors gracefully
- Visual Feedback: Show save indicator to user
localStorage Considerations
β οΈ Important Considerations
- Storage Limits: localStorage typically has 5-10MB limit per domain
- Privacy: Don't store sensitive data (passwords, credit cards) in localStorage
- Expiration: Consider adding timestamps and expiring old drafts
- Browser Support: Check for localStorage availability before use
- File Handling: Can't store File objects directlyβconvert to base64 if needed
- User Awareness: Inform users their data is saved locally
Enhanced localStorage Helper
Create src/utils/storage.ts for safer localStorage operations:
// src/utils/storage.ts
export const storage = {
// Check if localStorage is available
isAvailable(): boolean {
try {
const test = '__localStorage_test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch {
return false;
}
},
// Get item with error handling
getItem<T>(key: string): T | null {
if (!this.isAvailable()) return null;
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error(`Error reading from localStorage (${key}):`, error);
return null;
}
},
// Set item with error handling
setItem<T>(key: string, value: T): boolean {
if (!this.isAvailable()) return false;
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
console.error(`Error writing to localStorage (${key}):`, error);
return false;
}
},
// Remove item
removeItem(key: string): boolean {
if (!this.isAvailable()) return false;
try {
localStorage.removeItem(key);
return true;
} catch (error) {
console.error(`Error removing from localStorage (${key}):`, error);
return false;
}
},
// Clear all items
clear(): boolean {
if (!this.isAvailable()) return false;
try {
localStorage.clear();
return true;
} catch (error) {
console.error('Error clearing localStorage:', error);
return false;
}
},
// Get item with expiration
getItemWithExpiry<T>(key: string): T | null {
const item = this.getItem<{ value: T; expiry: number }>(key);
if (!item) return null;
const now = new Date().getTime();
if (now > item.expiry) {
this.removeItem(key);
return null;
}
return item.value;
},
// Set item with expiration (ttl in milliseconds)
setItemWithExpiry<T>(key: string, value: T, ttl: number): boolean {
const now = new Date().getTime();
const expiry = now + ttl;
return this.setItem(key, { value, expiry });
}
};
// Usage example:
// storage.setItemWithExpiry('draft', formData, 7 * 24 * 60 * 60 * 1000); // 7 days
π― Storage Utility Benefits
- Safety First: Checks localStorage availability before use
- Error Handling: Gracefully handles all storage errors
- Type Safety: TypeScript generics for type-safe storage
- Expiration Support: Automatically remove expired data
- Consistent API: Uniform interface for all operations
- Privacy Mode Support: Works even when localStorage is disabled
π§ͺ Testing Your Application
Before considering your registration system complete, thorough testing is essential. Here's a comprehensive testing checklist to ensure everything works correctly.
Functional Testing Checklist
β Step 1: Account Information
- β Email validation prevents invalid formats
- β Password strength indicator updates in real-time
- β Password visibility toggle works
- β Password confirmation validates matching
- β Username validation enforces character rules
- β All required field errors display correctly
- β Form submission only works when valid
β Step 2: Personal Details
- β Name fields accept appropriate characters
- β Date of birth validates age requirement (13+)
- β Phone number validation works correctly
- β Country dropdown populates correctly
- β State dropdown updates based on country
- β City dropdown updates based on state
- β ZIP code validation works for format
- β Optional fields can be left empty
- β Back button returns to Step 1 without validation
β Step 3: Profile Picture
- β Drag and drop works correctly
- β Click to browse opens file dialog
- β Image preview displays correctly
- β File type validation rejects invalid formats
- β File size validation rejects oversized files
- β Remove file button works
- β Skip button allows proceeding without upload
- β Error messages display for invalid files
β Step 4: Professional Info (Conditional)
- β Step appears only when user opts in
- β Step is skipped when user doesn't opt in
- β Required fields validate when opted in
- β LinkedIn URL validation works correctly
- β Industry dropdown works
- β Progress indicator shows correct total steps
β Review Step
- β All entered data displays correctly
- β Profile picture preview shows (if uploaded)
- β Edit buttons navigate to correct steps
- β Edited data persists when returning
- β Submit button triggers submission
- β Success message displays after submission
Auto-Save Testing
β Auto-Save Functionality
- β Data saves automatically after each step
- β Save indicator shows "Draft saved" message
- β Refresh page shows restore prompt
- β Accepting restore loads saved data
- β Rejecting restore clears draft
- β Clear draft button works
- β Draft clears after successful submission
- β Timestamp shows correct relative time
Progress Indicator Testing
β Navigation and Progress
- β Progress bar fills correctly
- β Current step is highlighted
- β Completed steps show checkmarks
- β Can click completed steps to return
- β Cannot click future steps
- β Step labels update based on conditional step
- β Scroll to top works on step change
Responsive Design Testing
# Test at these breakpoints:
Mobile: 320px, 375px, 414px
Tablet: 768px, 834px, 1024px
Desktop: 1280px, 1440px, 1920px
β Mobile Responsiveness
- β All text is readable without zooming
- β Touch targets are at least 44x44px
- β Forms don't overflow viewport
- β Progress indicator adapts to mobile
- β Dropdowns work on touch devices
- β File upload works on mobile browsers
- β Keyboard doesn't break layout
Accessibility Testing
β Accessibility (a11y)
- β Tab navigation works logically
- β Focus indicators are visible
- β Screen reader announces errors
- β ARIA labels are present and correct
- β Error messages are associated with fields
- β Required fields are indicated
- β Color contrast meets WCAG AA standards
- β Forms work without JavaScript
Browser Compatibility
| Browser | Minimum Version | Test Priority |
|---|---|---|
| Chrome | 90+ | High |
| Firefox | 88+ | High |
| Safari | 14+ | High |
| Edge | 90+ | Medium |
| Mobile Safari | 14+ | High |
| Chrome Mobile | 90+ | High |
Performance Testing
π‘ Performance Checklist
- β Initial page load is under 3 seconds
- β Step transitions are smooth (no lag)
- β Form fields respond instantly to input
- β File upload shows immediate feedback
- β No memory leaks from preview URLs
- β Auto-save doesn't block UI
- β Bundle size is reasonable (<500KB)
Edge Cases to Test
β οΈ Edge Cases
- Very long names: Test with 50-character names
- Special characters: Test with accented characters, emojis
- Copy-paste: Ensure pasted data validates correctly
- Browser back button: Check if data persists
- Multiple tabs: Test concurrent editing
- Slow connections: Test with throttled network
- Large files: Test with 4.9MB file (just under limit)
- Private browsing: Ensure localStorage fallback works
- Expired tokens: Simulate API errors
π Enhancement Ideas
Congratulations on building a production-ready registration system! Here are some ideas for taking it even further:
π¨ UI/UX Enhancements
Visual Improvements
- Animations: Add smooth transitions between steps using Framer Motion
- Custom Icons: Replace emojis with SVG icons from React Icons
- Theme Switcher: Add dark mode support
- Glassmorphism: Modern glass-effect cards and backgrounds
- Confetti Animation: Celebrate successful registration with confetti
- Loading Skeletons: Show skeleton screens while loading data
π§ Functional Enhancements
Additional Features
- Email Verification: Send verification email with confirmation link
- OAuth Integration: "Sign up with Google/GitHub" options
- Two-Factor Authentication: SMS or authenticator app 2FA
- Password Strength API: Check against HaveIBeenPwned database
- Real-time Username Check: API call to check username availability
- Address Autocomplete: Google Maps API for address suggestions
- Image Cropping: Allow users to crop profile pictures
- Multi-language Support: i18n for internationalization
- Analytics: Track drop-off rates at each step
π Security Enhancements
Security Features
- Rate Limiting: Prevent brute force attacks
- CAPTCHA: Add reCAPTCHA on submission
- Input Sanitization: Prevent XSS attacks
- CSRF Protection: Implement CSRF tokens
- Password Hashing: Use bcrypt or Argon2 on backend
- Secure File Upload: Scan uploaded files for malware
- Session Management: Implement secure session handling
π Advanced Features
// Example: Multi-language Support
import { useTranslation } from 'react-i18next';
const AccountInfoStep = () => {
const { t } = useTranslation();
return (
<h2>{t('registration.accountInfo.title')}</h2>
);
};
// Example: Real-time Username Validation
const checkUsernameAvailability = async (username: string) => {
const response = await fetch(`/api/check-username?username=${username}`);
const { available } = await response.json();
return available;
};
// Example: Address Autocomplete
import PlacesAutocomplete from 'react-places-autocomplete';
const AddressInput = () => {
const [address, setAddress] = useState('');
return (
<PlacesAutocomplete
value={address}
onChange={setAddress}
onSelect={handleSelect}
>
{/* Autocomplete UI */}
</PlacesAutocomplete>
);
};
π§ͺ Testing Enhancements
Testing Tools
- Unit Tests: Jest + React Testing Library for components
- Integration Tests: Test multi-step flow end-to-end
- E2E Tests: Cypress or Playwright for full user flows
- Visual Regression: Percy or Chromatic for UI changes
- Accessibility Tests: axe-core for a11y validation
- Performance Tests: Lighthouse CI for performance monitoring
ποΈ Architecture Improvements
// Example: State Machine for Step Management
import { createMachine } from 'xstate';
const registrationMachine = createMachine({
id: 'registration',
initial: 'accountInfo',
states: {
accountInfo: {
on: { NEXT: 'personalDetails' }
},
personalDetails: {
on: {
NEXT: 'profilePicture',
BACK: 'accountInfo'
}
},
profilePicture: {
on: {
NEXT: 'professionalInfo',
SKIP: 'review',
BACK: 'personalDetails'
}
},
// ... more states
}
});
// Example: React Query for Data Fetching
import { useQuery, useMutation } from '@tanstack/react-query';
const useSubmitRegistration = () => {
return useMutation({
mutationFn: async (data: RegistrationFormData) => {
const response = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(data)
});
return response.json();
},
onSuccess: () => {
// Clear cache, navigate, etc.
}
});
};
πΌ Portfolio Presentation Tips
- Deploy to Vercel or Netlify for live demo
- Create a README with screenshots and features
- Record a demo video showing the full flow
- Highlight complex features (auto-save, file upload, validation)
- Show mobile responsive design
- Document your technical decisions
- Include performance metrics (Lighthouse score)
π― Project Knowledge Check
Test your understanding of the registration system concepts!
Question 1: Why is auto-save functionality important in multi-step forms?
Question 2: What is the purpose of the zodResolver in React Hook Form?
Question 3: Why should blob URLs created with URL.createObjectURL() be cleaned up?
Question 4: What is the benefit of using a progress indicator in a multi-step form?
Question 5: Why is debouncing important in the auto-save functionality?
π Project Summary
π What You've Accomplished
Congratulations! You've just built a production-ready, multi-step registration system that demonstrates professional-level React and TypeScript skills. This is a significant achievement!
Skills You've Demonstrated:
- β Advanced Form Handling with React Hook Form
- β Type-Safe Validation using Zod schemas
- β Complex State Management across multiple components
- β File Upload Implementation with drag-and-drop
- β Auto-Save Functionality with localStorage
- β Conditional Form Logic and dynamic steps
- β Responsive UI Design for all devices
- β Accessibility with proper ARIA attributes
- β TypeScript Proficiency throughout the application
- β Component Architecture and code organization
π Technical Achievements
| Concept | Implementation | Business Value |
|---|---|---|
| Multi-Step Navigation | 4-5 steps with progress tracking | Reduced user overwhelm, higher completion rates |
| Comprehensive Validation | Zod schemas for each step | Better data quality, fewer errors |
| File Upload | Drag-drop with preview | Professional user experience |
| Auto-Save | localStorage with debouncing | Prevents data loss, improves UX |
| Conditional Logic | Dynamic step inclusion | Personalized user journey |
| Accessibility | ARIA, keyboard navigation | Inclusive design, legal compliance |
π Module 7 Complete!
You've now mastered all aspects of forms in React:
Module 7 Journey
- Lesson 7.1: Complex Form Handling basics
- Lesson 7.2: React Hook Form integration
- Lesson 7.3: Zod validation patterns
- Lesson 7.4: File upload implementation
- Lesson 7.5: Advanced form patterns
- Module Project: Complete registration system β
π Next Steps
How to Use This Project
- Portfolio: Add to your portfolio with screenshots and description
- GitHub: Push to GitHub with comprehensive README
- Deploy: Deploy to Vercel/Netlify for live demo
- Enhance: Add features from the enhancement section
- Interview: Be prepared to discuss technical decisions
- Reuse: Use components in future projects
π Additional Resources
- React Hook Form Documentation
- Zod Documentation
- ARIA Authoring Practices Guide
- Web.dev - Form Best Practices
- Mobile Form Design Best Practices
π Congratulations!
You've completed Module 7's capstone project!
You now have a portfolio-ready application that demonstrates your mastery of React, TypeScript, and advanced form handling. This is a significant milestone in your development journey!
Ready for Module 8: State Management and Architecture? π