This commit is contained in:
Koncept Kit
2026-02-01 19:53:45 +07:00
parent 5d085153f6
commit 68ee22c124
6 changed files with 2260 additions and 177 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
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';
@@ -6,189 +6,221 @@ import { Card } from '../components/ui/card';
import { toast } from 'sonner';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { ArrowRight, ArrowLeft } from 'lucide-react';
import RegistrationStepIndicator from '../components/registration/RegistrationStepIndicator';
import RegistrationStep1 from '../components/registration/RegistrationStep1';
import RegistrationStep2 from '../components/registration/RegistrationStep2';
import RegistrationStep3 from '../components/registration/RegistrationStep3';
import RegistrationStep4 from '../components/registration/RegistrationStep4';
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({
// Step 1: Personal & Partner Information
first_name: '',
last_name: '',
phone: '',
date_of_birth: '',
address: '',
city: '',
state: '',
zipcode: '',
lead_sources: [],
partner_first_name: '',
partner_last_name: '',
partner_is_member: false,
partner_plan_to_become_member: false,
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
// Step 2: Newsletter, Volunteer & Scholarship
referred_by_member_name: '',
newsletter_publish_name: false,
newsletter_publish_photo: false,
newsletter_publish_birthday: false,
newsletter_publish_none: false,
volunteer_interests: [],
scholarship_requested: false,
scholarship_reason: '',
// Step 3: Directory Settings
show_in_directory: false,
directory_email: '',
directory_bio: '',
directory_address: '',
directory_phone: '',
directory_dob: '',
directory_partner_name: '',
// Step 4: Account Credentials
email: '',
password: '',
confirmPassword: ''
});
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const validateStep1 = () => {
const required = ['first_name', 'last_name', 'phone', 'date_of_birth',
'address', 'city', 'state', 'zipcode'];
for (const field of required) {
if (!formData[field]?.trim()) {
toast.error('Please fill in all required fields');
return false;
// 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);
}
}
if (formData.lead_sources.length === 0) {
toast.error('Please select at least one option for how you heard about us');
return false;
}
return true;
};
};
const validateStep2 = () => {
const { newsletter_publish_name, newsletter_publish_photo,
newsletter_publish_birthday, newsletter_publish_none } = formData;
fetchSchema();
}, []);
if (!newsletter_publish_name && !newsletter_publish_photo &&
!newsletter_publish_birthday && !newsletter_publish_none) {
toast.error('Please select at least one newsletter publication preference');
return false;
}
// Get sorted steps
const sortedSteps = useMemo(() => {
if (!schema?.steps) return [];
return [...schema.steps].sort((a, b) => a.order - b.order);
}, [schema]);
if (formData.scholarship_requested && !formData.scholarship_reason?.trim()) {
toast.error('Please explain your scholarship request');
return false;
}
// Get current step data
const currentStepData = useMemo(() => {
return sortedSteps[currentStep - 1] || null;
}, [sortedSteps, currentStep]);
return true;
};
// Get hidden fields based on conditional rules
const hiddenFields = useMemo(() => {
return evaluateConditionalRules(schema, formData);
}, [schema, formData]);
const validateStep3 = () => {
return true; // No required fields
};
const validateStep4 = () => {
if (!formData.email || !formData.password || !formData.confirmPassword) {
toast.error('Please fill in all account fields');
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
toast.error('Please enter a valid email address');
return false;
}
if (formData.password.length < 6) {
toast.error('Password must be at least 6 characters');
return false;
}
if (formData.password !== formData.confirmPassword) {
toast.error('Passwords do not match');
return false;
}
return true;
};
// 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 = () => {
let isValid = false;
const { isValid, errors: stepErrors } = validateCurrentStep();
switch (currentStep) {
case 1: isValid = validateStep1(); break;
case 2: isValid = validateStep2(); break;
case 3: isValid = validateStep3(); break;
default: isValid = false;
if (!isValid) {
setErrors(stepErrors);
const firstErrorField = Object.keys(stepErrors)[0];
if (firstErrorField) {
toast.error(stepErrors[firstErrorField][0]);
}
return;
}
if (isValid) {
setCurrentStep(prev => Math.min(prev + 1, 4));
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
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();
// Final validation
if (!validateStep4()) return;
// 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 {
// Remove confirmPassword (client-side only)
const { confirmPassword, ...dataToSubmit } = formData;
// Prepare submission data
const submitData = { ...formData };
// Remove client-only fields
delete submitData.confirmPassword;
// Convert date fields to ISO format
const submitData = {
...dataToSubmit,
date_of_birth: new Date(dataToSubmit.date_of_birth).toISOString(),
directory_dob: dataToSubmit.directory_dob
? new Date(dataToSubmit.directory_dob).toISOString()
: null
};
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) {
toast.error(error.response?.data?.detail || 'Registration failed. Please try again.');
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">
<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>
@@ -196,47 +228,34 @@ const Register = () => {
<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" }}>
<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" }}>
<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">
<RegistrationStepIndicator currentStep={currentStep} />
{currentStep === 1 && (
<RegistrationStep1
formData={formData}
setFormData={setFormData}
handleInputChange={handleInputChange}
/>
{/* Step Indicator */}
{sortedSteps.length > 1 && (
<DynamicStepIndicator steps={sortedSteps} currentStep={currentStep} />
)}
{currentStep === 2 && (
<RegistrationStep2
formData={formData}
setFormData={setFormData}
handleInputChange={handleInputChange}
/>
)}
{currentStep === 3 && (
<RegistrationStep3
formData={formData}
setFormData={setFormData}
handleInputChange={handleInputChange}
/>
)}
{currentStep === 4 && (
<RegistrationStep4
formData={formData}
handleInputChange={handleInputChange}
/>
)}
{/* 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">
@@ -245,7 +264,7 @@ const Register = () => {
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)]"
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
@@ -254,7 +273,7 @@ const Register = () => {
<div></div>
)}
{currentStep < 4 ? (
{currentStep < sortedSteps.length ? (
<Button
type="button"
onClick={handleNext}
@@ -267,16 +286,28 @@ const Register = () => {
<Button
type="submit"
disabled={loading}
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-backgroundrounded-full px-6 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50 disabled:cursor-not-allowed"
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 ? 'Creating Account...' : 'Create Account'}
<ArrowRight className="ml-2 h-5 w-5" />
{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" }}>
<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

File diff suppressed because it is too large Load Diff