Changes
This commit is contained in:
@@ -47,6 +47,7 @@ import AdminGallery from './pages/admin/AdminGallery';
|
||||
import AdminNewsletters from './pages/admin/AdminNewsletters';
|
||||
import AdminFinancials from './pages/admin/AdminFinancials';
|
||||
import AdminBylaws from './pages/admin/AdminBylaws';
|
||||
import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder';
|
||||
import History from './pages/History';
|
||||
import MissionValues from './pages/MissionValues';
|
||||
import BoardOfDirectors from './pages/BoardOfDirectors';
|
||||
@@ -304,6 +305,7 @@ function App() {
|
||||
<Route path="permissions" element={<AdminRoles />} />
|
||||
<Route path="member-tiers" element={<AdminMemberTiers />} />
|
||||
<Route path="theme" element={<AdminTheme />} />
|
||||
<Route path="registration" element={<AdminRegistrationBuilder />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 - Catch all undefined routes */}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { CreditCard, Shield, Star, Palette } from 'lucide-react';
|
||||
import { CreditCard, Shield, Star, Palette, FileEdit } from 'lucide-react';
|
||||
|
||||
const settingsItems = [
|
||||
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
|
||||
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
|
||||
{ label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star },
|
||||
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
|
||||
{ label: 'Registration Form', path: '/admin/settings/registration', icon: FileEdit },
|
||||
];
|
||||
|
||||
const SettingsTabs = () => {
|
||||
|
||||
411
src/components/registration/DynamicFormField.js
Normal file
411
src/components/registration/DynamicFormField.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import React from 'react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
|
||||
/**
|
||||
* DynamicFormField - Renders form fields based on schema configuration
|
||||
*
|
||||
* Supports field types:
|
||||
* - text, email, phone, password: Input fields
|
||||
* - date: Date picker input
|
||||
* - textarea: Multi-line text input
|
||||
* - checkbox: Single checkbox
|
||||
* - radio: Radio button group
|
||||
* - dropdown: Select dropdown
|
||||
* - multiselect: Checkbox group for multiple selections
|
||||
* - address_group: Group of address-related fields
|
||||
* - file_upload: File upload input
|
||||
*/
|
||||
const DynamicFormField = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
errors = [],
|
||||
formData = {},
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
required,
|
||||
placeholder,
|
||||
options = [],
|
||||
rows = 4,
|
||||
validation = {},
|
||||
} = field;
|
||||
|
||||
const hasError = errors.length > 0;
|
||||
const errorMessage = errors[0];
|
||||
|
||||
// Common input className
|
||||
const inputClassName = `h-14 rounded-xl border-2 ${
|
||||
hasError
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`;
|
||||
|
||||
// Handle change for different field types
|
||||
const handleInputChange = (e) => {
|
||||
const { value: newValue, type: inputType, checked } = e.target;
|
||||
if (inputType === 'checkbox') {
|
||||
onChange(id, checked);
|
||||
} else {
|
||||
onChange(id, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (newValue) => {
|
||||
onChange(id, newValue);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (checked) => {
|
||||
onChange(id, checked);
|
||||
};
|
||||
|
||||
const handleMultiselectChange = (optionValue) => {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
const newValues = currentValues.includes(optionValue)
|
||||
? currentValues.filter((v) => v !== optionValue)
|
||||
: [...currentValues, optionValue];
|
||||
onChange(id, newValues);
|
||||
};
|
||||
|
||||
// Render error message
|
||||
const renderError = () => {
|
||||
if (!hasError) return null;
|
||||
return (
|
||||
<p className="text-sm text-red-500 mt-1">{errorMessage}</p>
|
||||
);
|
||||
};
|
||||
|
||||
// Render label
|
||||
const renderLabel = () => (
|
||||
<Label htmlFor={id} className={hasError ? 'text-red-500' : ''}>
|
||||
{label} {required && '*'}
|
||||
</Label>
|
||||
);
|
||||
|
||||
// Render based on field type
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
case 'phone':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type={type === 'phone' ? 'tel' : type}
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="password"
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
minLength={validation.minLength}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="date"
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Textarea
|
||||
id={id}
|
||||
name={id}
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`rounded-xl border-2 ${
|
||||
hasError
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
name={id}
|
||||
checked={value || false}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={`text-base cursor-pointer ${hasError ? 'text-red-500' : ''}`}
|
||||
>
|
||||
{label} {required && '*'}
|
||||
</Label>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<RadioGroup
|
||||
value={value || ''}
|
||||
onValueChange={handleSelectChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`${id}-${option.value}`}
|
||||
data-testid={`field-${id}-${option.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${id}-${option.value}`}
|
||||
className="text-base cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'dropdown':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Select value={value || ''} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger
|
||||
className={`h-14 rounded-xl border-2 ${
|
||||
hasError
|
||||
? 'border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`}
|
||||
data-testid={`field-${id}`}
|
||||
>
|
||||
<SelectValue placeholder={placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'multiselect':
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<div className="space-y-3">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${id}-${option.value}`}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onCheckedChange={() => handleMultiselectChange(option.value)}
|
||||
data-testid={`field-${id}-${option.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${id}-${option.value}`}
|
||||
className="text-base cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'address_group':
|
||||
// Address group renders multiple related fields
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{renderLabel()}
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id={`${id}_address`}
|
||||
name={`${id}_address`}
|
||||
placeholder="Street Address"
|
||||
value={formData[`${id}_address`] || ''}
|
||||
onChange={(e) => onChange(`${id}_address`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
id={`${id}_city`}
|
||||
name={`${id}_city`}
|
||||
placeholder="City"
|
||||
value={formData[`${id}_city`] || ''}
|
||||
onChange={(e) => onChange(`${id}_city`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}_state`}
|
||||
name={`${id}_state`}
|
||||
placeholder="State"
|
||||
value={formData[`${id}_state`] || ''}
|
||||
onChange={(e) => onChange(`${id}_state`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}_zipcode`}
|
||||
name={`${id}_zipcode`}
|
||||
placeholder="Zipcode"
|
||||
value={formData[`${id}_zipcode`] || ''}
|
||||
onChange={(e) => onChange(`${id}_zipcode`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'file_upload':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="file"
|
||||
accept={field.allowed_types?.join(',')}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
onChange(id, file);
|
||||
}}
|
||||
className={`h-14 rounded-xl border-2 pt-3 ${
|
||||
hasError
|
||||
? 'border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{field.max_size_mb && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Max file size: {field.max_size_mb}MB
|
||||
</p>
|
||||
)}
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
console.warn(`Unknown field type: ${type}`);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get width class based on field width configuration
|
||||
*/
|
||||
export const getWidthClass = (width) => {
|
||||
switch (width) {
|
||||
case 'half':
|
||||
return 'md:col-span-1';
|
||||
case 'third':
|
||||
return 'md:col-span-1';
|
||||
case 'two-thirds':
|
||||
return 'md:col-span-2';
|
||||
case 'full':
|
||||
default:
|
||||
return 'md:col-span-2';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get grid columns class based on field widths in a row
|
||||
*/
|
||||
export const getGridClass = (fields) => {
|
||||
const hasThird = fields.some((f) => f.width === 'third');
|
||||
if (hasThird) {
|
||||
return 'grid md:grid-cols-3 gap-4';
|
||||
}
|
||||
return 'grid md:grid-cols-2 gap-4';
|
||||
};
|
||||
|
||||
export default DynamicFormField;
|
||||
482
src/components/registration/DynamicRegistrationForm.js
Normal file
482
src/components/registration/DynamicRegistrationForm.js
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import DynamicFormField, { getWidthClass } from './DynamicFormField';
|
||||
|
||||
/**
|
||||
* DynamicRegistrationForm - Renders the entire registration form from schema
|
||||
*
|
||||
* Features:
|
||||
* - Renders steps and sections based on schema
|
||||
* - Handles conditional field visibility
|
||||
* - Supports step navigation
|
||||
* - Validates fields per step
|
||||
*/
|
||||
const DynamicRegistrationForm = ({
|
||||
schema,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
currentStep,
|
||||
errors = {},
|
||||
}) => {
|
||||
// Get current step data
|
||||
const stepData = useMemo(() => {
|
||||
const steps = schema?.steps || [];
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
return sortedSteps[currentStep - 1] || null;
|
||||
}, [schema, currentStep]);
|
||||
|
||||
// Evaluate conditional rules to determine which fields are visible
|
||||
const hiddenFields = useMemo(() => {
|
||||
const rules = schema?.conditional_rules || [];
|
||||
const hidden = new Set();
|
||||
|
||||
// First pass: collect fields that have "show" rules (hidden by default)
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'show') {
|
||||
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: evaluate rules and show/hide fields
|
||||
for (const rule of rules) {
|
||||
const {
|
||||
trigger_field,
|
||||
trigger_operator = 'equals',
|
||||
trigger_value,
|
||||
action,
|
||||
target_fields = [],
|
||||
} = rule;
|
||||
|
||||
const fieldValue = formData[trigger_field];
|
||||
let conditionMet = false;
|
||||
|
||||
switch (trigger_operator) {
|
||||
case 'equals':
|
||||
conditionMet = fieldValue === trigger_value;
|
||||
break;
|
||||
case 'not_equals':
|
||||
conditionMet = fieldValue !== trigger_value;
|
||||
break;
|
||||
case 'contains':
|
||||
conditionMet = Array.isArray(fieldValue)
|
||||
? fieldValue.includes(trigger_value)
|
||||
: String(fieldValue || '').includes(trigger_value);
|
||||
break;
|
||||
case 'not_empty':
|
||||
conditionMet = Boolean(fieldValue);
|
||||
break;
|
||||
case 'empty':
|
||||
conditionMet = !Boolean(fieldValue);
|
||||
break;
|
||||
default:
|
||||
conditionMet = false;
|
||||
}
|
||||
|
||||
if (conditionMet) {
|
||||
if (action === 'show') {
|
||||
target_fields.forEach((fieldId) => hidden.delete(fieldId));
|
||||
} else if (action === 'hide') {
|
||||
target_fields.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hidden;
|
||||
}, [schema, formData]);
|
||||
|
||||
// Handle field change
|
||||
const handleFieldChange = useCallback(
|
||||
(fieldId, value) => {
|
||||
onFormDataChange((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}));
|
||||
},
|
||||
[onFormDataChange]
|
||||
);
|
||||
|
||||
// Check if a field is visible
|
||||
const isFieldVisible = useCallback(
|
||||
(fieldId) => {
|
||||
return !hiddenFields.has(fieldId);
|
||||
},
|
||||
[hiddenFields]
|
||||
);
|
||||
|
||||
// Get errors for a specific field
|
||||
const getFieldErrors = useCallback(
|
||||
(fieldId) => {
|
||||
return errors[fieldId] || [];
|
||||
},
|
||||
[errors]
|
||||
);
|
||||
|
||||
// Group fields by their width for rendering
|
||||
const groupFieldsByRow = (fields) => {
|
||||
const rows = [];
|
||||
let currentRow = [];
|
||||
let currentRowWidth = 0;
|
||||
|
||||
const visibleFields = fields.filter((f) => isFieldVisible(f.id));
|
||||
|
||||
for (const field of visibleFields) {
|
||||
const width = field.width || 'full';
|
||||
let widthValue = 1;
|
||||
|
||||
if (width === 'half') widthValue = 0.5;
|
||||
else if (width === 'third') widthValue = 0.33;
|
||||
else if (width === 'two-thirds') widthValue = 0.67;
|
||||
|
||||
if (currentRowWidth + widthValue > 1) {
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
currentRow = [field];
|
||||
currentRowWidth = widthValue;
|
||||
} else {
|
||||
currentRow.push(field);
|
||||
currentRowWidth += widthValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
if (!stepData) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No step data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Step Header */}
|
||||
{stepData.description && (
|
||||
<p
|
||||
className="text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{stepData.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{stepData.sections
|
||||
?.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const visibleFields = section.fields?.filter((f) =>
|
||||
isFieldVisible(f.id)
|
||||
);
|
||||
|
||||
// Skip empty sections
|
||||
if (!visibleFields || visibleFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldRows = groupFieldsByRow(
|
||||
section.fields?.sort((a, b) => a.order - b.order) || []
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={section.id} className="space-y-4">
|
||||
{/* Section Title */}
|
||||
{section.title && (
|
||||
<h2
|
||||
className="text-2xl font-semibold text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{section.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Section Description */}
|
||||
{section.description && (
|
||||
<p className="text-muted-foreground">{section.description}</p>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
{fieldRows.map((row, rowIndex) => {
|
||||
// Determine grid class based on field widths
|
||||
const hasThird = row.some((f) => f.width === 'third');
|
||||
const hasHalf = row.some((f) => f.width === 'half');
|
||||
const gridCols = hasThird
|
||||
? 'grid md:grid-cols-3 gap-4'
|
||||
: hasHalf
|
||||
? 'grid md:grid-cols-2 gap-4'
|
||||
: '';
|
||||
|
||||
if (row.length === 1 && !hasHalf && !hasThird) {
|
||||
// Single full-width field
|
||||
const field = row[0];
|
||||
return (
|
||||
<DynamicFormField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={formData[field.id]}
|
||||
onChange={handleFieldChange}
|
||||
errors={getFieldErrors(field.id)}
|
||||
formData={formData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`row-${rowIndex}`} className={gridCols}>
|
||||
{row.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className={getWidthClass(field.width)}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
value={formData[field.id]}
|
||||
onChange={handleFieldChange}
|
||||
errors={getFieldErrors(field.id)}
|
||||
formData={formData}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* DynamicStepIndicator - Renders step progress indicator
|
||||
*/
|
||||
export const DynamicStepIndicator = ({ steps, currentStep }) => {
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{sortedSteps.map((step, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isActive = stepNumber === currentStep;
|
||||
const isCompleted = stepNumber < currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center flex-1">
|
||||
{/* Step Circle */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-purple text-white'
|
||||
: isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? '✓' : stepNumber}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-2 text-sm text-center hidden md:block ${
|
||||
isActive ? 'text-brand-purple font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < sortedSteps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-4 rounded ${
|
||||
isCompleted
|
||||
? 'bg-green-500'
|
||||
: 'bg-[var(--neutral-800)]'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a single step based on schema
|
||||
*/
|
||||
export const validateStep = (stepData, formData, hiddenFields) => {
|
||||
const errors = {};
|
||||
|
||||
if (!stepData?.sections) return { isValid: true, errors };
|
||||
|
||||
for (const section of stepData.sections) {
|
||||
// Check section-level validation (e.g., atLeastOne)
|
||||
const sectionValidation = section.validation || {};
|
||||
if (sectionValidation.atLeastOne) {
|
||||
const fieldIds = (section.fields || []).map((f) => f.id);
|
||||
const hasValue = fieldIds.some((id) => {
|
||||
if (hiddenFields.has(id)) return true; // Skip hidden fields
|
||||
const value = formData[id];
|
||||
return Boolean(value);
|
||||
});
|
||||
if (!hasValue) {
|
||||
// Add error to first field in section
|
||||
const firstFieldId = fieldIds[0];
|
||||
if (firstFieldId) {
|
||||
errors[firstFieldId] = [
|
||||
sectionValidation.message ||
|
||||
`At least one field in ${section.title || 'this section'} is required`,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check field-level validation
|
||||
for (const field of section.fields || []) {
|
||||
const { id, required, validation = {}, type, label } = field;
|
||||
|
||||
// Skip hidden fields
|
||||
if (hiddenFields.has(id)) continue;
|
||||
|
||||
// Skip client-only fields for server validation
|
||||
if (field.client_only && field.id !== 'confirmPassword') continue;
|
||||
|
||||
const value = formData[id];
|
||||
|
||||
// Required check
|
||||
if (required) {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
errors[id] = [`${label || id} is required`];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip further validation if value is empty
|
||||
if (!value && value !== false) continue;
|
||||
|
||||
// Type-specific validation
|
||||
const fieldErrors = [];
|
||||
|
||||
if (type === 'email') {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
fieldErrors.push('Please enter a valid email address');
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'password') {
|
||||
if (validation.minLength && value.length < validation.minLength) {
|
||||
fieldErrors.push(
|
||||
`Password must be at least ${validation.minLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'text' || type === 'textarea') {
|
||||
if (validation.minLength && value.length < validation.minLength) {
|
||||
fieldErrors.push(
|
||||
`${label || id} must be at least ${validation.minLength} characters`
|
||||
);
|
||||
}
|
||||
if (validation.maxLength && value.length > validation.maxLength) {
|
||||
fieldErrors.push(
|
||||
`${label || id} must be at most ${validation.maxLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Match field validation (for confirmPassword)
|
||||
if (validation.matchField) {
|
||||
if (value !== formData[validation.matchField]) {
|
||||
fieldErrors.push('Passwords do not match');
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldErrors.length > 0) {
|
||||
errors[id] = fieldErrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate conditional rules to get hidden fields set
|
||||
*/
|
||||
export const evaluateConditionalRules = (schema, formData) => {
|
||||
const rules = schema?.conditional_rules || [];
|
||||
const hidden = new Set();
|
||||
|
||||
// First pass: collect fields that have "show" rules (hidden by default)
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'show') {
|
||||
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: evaluate rules and show/hide fields
|
||||
for (const rule of rules) {
|
||||
const {
|
||||
trigger_field,
|
||||
trigger_operator = 'equals',
|
||||
trigger_value,
|
||||
action,
|
||||
target_fields = [],
|
||||
} = rule;
|
||||
|
||||
const fieldValue = formData[trigger_field];
|
||||
let conditionMet = false;
|
||||
|
||||
switch (trigger_operator) {
|
||||
case 'equals':
|
||||
conditionMet = fieldValue === trigger_value;
|
||||
break;
|
||||
case 'not_equals':
|
||||
conditionMet = fieldValue !== trigger_value;
|
||||
break;
|
||||
case 'contains':
|
||||
conditionMet = Array.isArray(fieldValue)
|
||||
? fieldValue.includes(trigger_value)
|
||||
: String(fieldValue || '').includes(trigger_value);
|
||||
break;
|
||||
case 'not_empty':
|
||||
conditionMet = Boolean(fieldValue);
|
||||
break;
|
||||
case 'empty':
|
||||
conditionMet = !Boolean(fieldValue);
|
||||
break;
|
||||
default:
|
||||
conditionMet = false;
|
||||
}
|
||||
|
||||
if (conditionMet) {
|
||||
if (action === 'show') {
|
||||
target_fields.forEach((fieldId) => hidden.delete(fieldId));
|
||||
} else if (action === 'hide') {
|
||||
target_fields.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hidden;
|
||||
};
|
||||
|
||||
export default DynamicRegistrationForm;
|
||||
@@ -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
|
||||
}));
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (formData.lead_sources.length === 0) {
|
||||
toast.error('Please select at least one option for how you heard about us');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
fetchSchema();
|
||||
}, []);
|
||||
|
||||
const validateStep2 = () => {
|
||||
const { newsletter_publish_name, newsletter_publish_photo,
|
||||
newsletter_publish_birthday, newsletter_publish_none } = formData;
|
||||
// Get sorted steps
|
||||
const sortedSteps = useMemo(() => {
|
||||
if (!schema?.steps) return [];
|
||||
return [...schema.steps].sort((a, b) => a.order - b.order);
|
||||
}, [schema]);
|
||||
|
||||
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 current step data
|
||||
const currentStepData = useMemo(() => {
|
||||
return sortedSteps[currentStep - 1] || null;
|
||||
}, [sortedSteps, currentStep]);
|
||||
|
||||
if (formData.scholarship_requested && !formData.scholarship_reason?.trim()) {
|
||||
toast.error('Please explain your scholarship request');
|
||||
return false;
|
||||
}
|
||||
// Get hidden fields based on conditional rules
|
||||
const hiddenFields = useMemo(() => {
|
||||
return evaluateConditionalRules(schema, formData);
|
||||
}, [schema, formData]);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
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));
|
||||
setErrors({});
|
||||
setCurrentStep((prev) => Math.min(prev + 1, sortedSteps.length));
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle previous step
|
||||
const handleBack = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
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
|
||||
{/* Dynamic Form Content */}
|
||||
<DynamicRegistrationForm
|
||||
schema={schema}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
handleInputChange={handleInputChange}
|
||||
onFormDataChange={setFormData}
|
||||
currentStep={currentStep}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<RegistrationStep3
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
handleInputChange={handleInputChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<RegistrationStep4
|
||||
formData={formData}
|
||||
handleInputChange={handleInputChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between items-center pt-6">
|
||||
@@ -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'}
|
||||
{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
|
||||
|
||||
1156
src/pages/admin/AdminRegistrationBuilder.js
Normal file
1156
src/pages/admin/AdminRegistrationBuilder.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user