326 lines
11 KiB
JavaScript
326 lines
11 KiB
JavaScript
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { useNavigate, Link } from 'react-router-dom';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { Button } from '../components/ui/button';
|
|
import { Card } from '../components/ui/card';
|
|
import { toast } from 'sonner';
|
|
import PublicNavbar from '../components/PublicNavbar';
|
|
import PublicFooter from '../components/PublicFooter';
|
|
import { ArrowRight, ArrowLeft, Loader2 } from 'lucide-react';
|
|
import DynamicRegistrationForm, {
|
|
DynamicStepIndicator,
|
|
validateStep,
|
|
evaluateConditionalRules,
|
|
} from '../components/registration/DynamicRegistrationForm';
|
|
import api from '../utils/api';
|
|
|
|
// Fallback schema for when API is unavailable
|
|
const FALLBACK_SCHEMA = {
|
|
version: '1.0',
|
|
steps: [
|
|
{
|
|
id: 'step_account',
|
|
title: 'Account Setup',
|
|
description: 'Create your account credentials.',
|
|
order: 1,
|
|
sections: [
|
|
{
|
|
id: 'section_credentials',
|
|
title: 'Account Credentials',
|
|
order: 1,
|
|
fields: [
|
|
{ id: 'first_name', type: 'text', label: 'First Name', required: true, is_fixed: true, mapping: 'first_name', width: 'half', order: 1 },
|
|
{ id: 'last_name', type: 'text', label: 'Last Name', required: true, is_fixed: true, mapping: 'last_name', width: 'half', order: 2 },
|
|
{ id: 'email', type: 'email', label: 'Email Address', required: true, is_fixed: true, mapping: 'email', width: 'full', order: 3 },
|
|
{ id: 'password', type: 'password', label: 'Password', required: true, is_fixed: true, mapping: 'password', validation: { minLength: 6 }, width: 'half', order: 4 },
|
|
{ id: 'confirmPassword', type: 'password', label: 'Confirm Password', required: true, is_fixed: true, client_only: true, width: 'half', order: 5, validation: { matchField: 'password' } },
|
|
{ id: 'accepts_tos', type: 'checkbox', label: 'I accept the Terms of Service and Privacy Policy', required: true, is_fixed: true, mapping: 'accepts_tos', width: 'full', order: 6 },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
conditional_rules: [],
|
|
fixed_fields: ['email', 'password', 'first_name', 'last_name', 'accepts_tos'],
|
|
};
|
|
|
|
const Register = () => {
|
|
const navigate = useNavigate();
|
|
const { register } = useAuth();
|
|
const [loading, setLoading] = useState(false);
|
|
const [schemaLoading, setSchemaLoading] = useState(true);
|
|
const [schema, setSchema] = useState(null);
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
const [formData, setFormData] = useState({});
|
|
const [errors, setErrors] = useState({});
|
|
|
|
// Fetch registration schema on mount
|
|
useEffect(() => {
|
|
const fetchSchema = async () => {
|
|
try {
|
|
const response = await api.get('/registration/schema');
|
|
setSchema(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to load registration schema:', error);
|
|
toast.error('Failed to load registration form. Using default form.');
|
|
setSchema(FALLBACK_SCHEMA);
|
|
} finally {
|
|
setSchemaLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchSchema();
|
|
}, []);
|
|
|
|
// Get sorted steps
|
|
const sortedSteps = useMemo(() => {
|
|
if (!schema?.steps) return [];
|
|
return [...schema.steps].sort((a, b) => a.order - b.order);
|
|
}, [schema]);
|
|
|
|
// Get current step data
|
|
const currentStepData = useMemo(() => {
|
|
return sortedSteps[currentStep - 1] || null;
|
|
}, [sortedSteps, currentStep]);
|
|
|
|
// Get hidden fields based on conditional rules
|
|
const hiddenFields = useMemo(() => {
|
|
return evaluateConditionalRules(schema, formData);
|
|
}, [schema, formData]);
|
|
|
|
// Validate current step
|
|
const validateCurrentStep = useCallback(() => {
|
|
if (!currentStepData) return { isValid: true, errors: {} };
|
|
return validateStep(currentStepData, formData, hiddenFields);
|
|
}, [currentStepData, formData, hiddenFields]);
|
|
|
|
// Handle next step
|
|
const handleNext = () => {
|
|
const { isValid, errors: stepErrors } = validateCurrentStep();
|
|
|
|
if (!isValid) {
|
|
setErrors(stepErrors);
|
|
const firstErrorField = Object.keys(stepErrors)[0];
|
|
if (firstErrorField) {
|
|
toast.error(stepErrors[firstErrorField][0]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setErrors({});
|
|
setCurrentStep((prev) => Math.min(prev + 1, sortedSteps.length));
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
// Handle previous step
|
|
const handleBack = () => {
|
|
setErrors({});
|
|
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
// Handle form submission
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
|
|
// Validate final step
|
|
const { isValid, errors: stepErrors } = validateCurrentStep();
|
|
if (!isValid) {
|
|
setErrors(stepErrors);
|
|
const firstErrorField = Object.keys(stepErrors)[0];
|
|
if (firstErrorField) {
|
|
toast.error(stepErrors[firstErrorField][0]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
// Prepare submission data
|
|
const submitData = { ...formData };
|
|
|
|
// Remove client-only fields
|
|
delete submitData.confirmPassword;
|
|
|
|
// Convert date fields to ISO format
|
|
if (submitData.date_of_birth) {
|
|
submitData.date_of_birth = new Date(submitData.date_of_birth).toISOString();
|
|
}
|
|
if (submitData.directory_dob) {
|
|
submitData.directory_dob = new Date(submitData.directory_dob).toISOString();
|
|
}
|
|
|
|
// Ensure boolean fields are actually booleans
|
|
const booleanFields = [
|
|
'partner_is_member',
|
|
'partner_plan_to_become_member',
|
|
'newsletter_publish_name',
|
|
'newsletter_publish_photo',
|
|
'newsletter_publish_birthday',
|
|
'newsletter_publish_none',
|
|
'scholarship_requested',
|
|
'show_in_directory',
|
|
'accepts_tos',
|
|
];
|
|
|
|
for (const field of booleanFields) {
|
|
if (field in submitData) {
|
|
submitData[field] = Boolean(submitData[field]);
|
|
}
|
|
}
|
|
|
|
// Ensure array fields are arrays
|
|
const arrayFields = ['lead_sources', 'volunteer_interests'];
|
|
for (const field of arrayFields) {
|
|
if (field in submitData && !Array.isArray(submitData[field])) {
|
|
submitData[field] = submitData[field] ? [submitData[field]] : [];
|
|
}
|
|
}
|
|
|
|
await register(submitData);
|
|
toast.success('Please check your email for a confirmation email.');
|
|
navigate('/login');
|
|
} catch (error) {
|
|
const errorMessage = error.response?.data?.detail;
|
|
if (typeof errorMessage === 'object' && errorMessage.errors) {
|
|
// Handle structured validation errors
|
|
const errorList = errorMessage.errors;
|
|
toast.error(errorList[0] || 'Registration failed');
|
|
} else {
|
|
toast.error(errorMessage || 'Registration failed. Please try again.');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Show loading state while fetching schema
|
|
if (schemaLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<PublicNavbar />
|
|
<div className="max-w-4xl mx-auto px-6 py-12 flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-center">
|
|
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-brand-purple" />
|
|
<p className="text-muted-foreground">Loading registration form...</p>
|
|
</div>
|
|
</div>
|
|
<PublicFooter />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<PublicNavbar />
|
|
|
|
<div className="max-w-4xl mx-auto px-6 py-12">
|
|
<div className="mb-8">
|
|
<Link
|
|
to="/"
|
|
className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Home
|
|
</Link>
|
|
</div>
|
|
|
|
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
|
<div className="mb-8">
|
|
<h1
|
|
className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
Join Our Community
|
|
</h1>
|
|
<p
|
|
className="text-lg text-brand-purple"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
Fill out the form below to start your membership journey.
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-8" data-testid="register-form">
|
|
{/* Step Indicator */}
|
|
{sortedSteps.length > 1 && (
|
|
<DynamicStepIndicator steps={sortedSteps} currentStep={currentStep} />
|
|
)}
|
|
|
|
{/* Dynamic Form Content */}
|
|
<DynamicRegistrationForm
|
|
schema={schema}
|
|
formData={formData}
|
|
onFormDataChange={setFormData}
|
|
currentStep={currentStep}
|
|
errors={errors}
|
|
/>
|
|
|
|
{/* Navigation Buttons */}
|
|
<div className="flex justify-between items-center pt-6">
|
|
{currentStep > 1 ? (
|
|
<Button
|
|
type="button"
|
|
onClick={handleBack}
|
|
variant="outline"
|
|
className="rounded-full px-6 py-6 text-lg border-2 border-[var(--neutral-800)] hover:border-brand-purple text-[var(--purple-ink)]"
|
|
>
|
|
<ArrowLeft className="mr-2 h-5 w-5" />
|
|
Back
|
|
</Button>
|
|
) : (
|
|
<div></div>
|
|
)}
|
|
|
|
{currentStep < sortedSteps.length ? (
|
|
<Button
|
|
type="button"
|
|
onClick={handleNext}
|
|
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform"
|
|
>
|
|
Next
|
|
<ArrowRight className="ml-2 h-5 w-5" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-background rounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
|
|
data-testid="submit-register-button"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
Creating Account...
|
|
</>
|
|
) : (
|
|
<>
|
|
Create Account
|
|
<ArrowRight className="ml-2 h-5 w-5" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<p
|
|
className="text-center text-brand-purple mt-4"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
Already have an account?{' '}
|
|
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
|
|
Login here
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
|
|
<PublicFooter />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Register;
|