Files
membership-fe/src/pages/admin/AdminRegistrationBuilder.js
2026-02-04 10:42:07 -06:00

1089 lines
42 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea';
import { Checkbox } from '../../components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../../components/ui/dialog';
import { toast } from 'sonner';
import api from '../../utils/api';
import {
Loader2,
Save,
RotateCcw,
Eye,
Plus,
Trash2,
GripVertical,
Type,
Mail,
Phone,
Calendar,
List,
CheckSquare,
Circle,
AlignLeft,
Upload,
Lock,
Settings,
Zap,
X
} from 'lucide-react';
// Field type icons
const FIELD_TYPE_ICONS = {
text: Type,
email: Mail,
phone: Phone,
date: Calendar,
dropdown: List,
checkbox: CheckSquare,
radio: Circle,
multiselect: CheckSquare,
textarea: AlignLeft,
file_upload: Upload,
password: Lock,
address_group: Type,
};
// Field type labels
const FIELD_TYPE_LABELS = {
text: 'Text Input',
email: 'Email',
phone: 'Phone',
date: 'Date',
dropdown: 'Dropdown',
checkbox: 'Checkbox',
radio: 'Radio Group',
multiselect: 'Multi-Select',
textarea: 'Text Area',
file_upload: 'File Upload',
password: 'Password',
address_group: 'Address Group',
};
const AdminRegistrationBuilder = () => {
const [schema, setSchema] = useState(null);
const [originalSchema, setOriginalSchema] = useState(null);
const [fieldTypes, setFieldTypes] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// UI State
const [selectedStep, setSelectedStep] = useState(null);
const [selectedSection, setSelectedSection] = useState(null);
const [selectedField, setSelectedField] = useState(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [addStepDialogOpen, setAddStepDialogOpen] = useState(false);
const [addSectionDialogOpen, setAddSectionDialogOpen] = useState(false);
const [addFieldDialogOpen, setAddFieldDialogOpen] = useState(false);
const [conditionalDialogOpen, setConditionalDialogOpen] = useState(false);
// Form state for dialogs
const [newStepData, setNewStepData] = useState({ title: '', description: '' });
const [newSectionData, setNewSectionData] = useState({ title: '', description: '' });
const [newFieldType, setNewFieldType] = useState('text');
// Fetch schema and field types on mount
useEffect(() => {
fetchSchema();
fetchFieldTypes();
}, []);
const fetchSchema = async () => {
try {
const response = await api.get('/admin/registration/schema');
setSchema(response.data.schema);
setOriginalSchema(JSON.parse(JSON.stringify(response.data.schema)));
if (response.data.schema?.steps?.length > 0) {
setSelectedStep(response.data.schema.steps[0].id);
}
} catch (error) {
toast.error('Failed to load registration schema');
console.error(error);
} finally {
setLoading(false);
}
};
const fetchFieldTypes = async () => {
try {
const response = await api.get('/admin/registration/field-types');
setFieldTypes(response.data);
} catch (error) {
console.error('Failed to load field types:', error);
}
};
// Check for changes
useEffect(() => {
if (schema && originalSchema) {
setHasChanges(JSON.stringify(schema) !== JSON.stringify(originalSchema));
}
}, [schema, originalSchema]);
// Save schema
const handleSave = async () => {
setSaving(true);
try {
// Validate first
const validateResponse = await api.post('/admin/registration/schema/validate', {
schema_data: schema,
});
if (!validateResponse.data.valid) {
toast.error(validateResponse.data.errors.join(', '));
return;
}
// Save
await api.put('/admin/registration/schema', { schema_data: schema });
setOriginalSchema(JSON.parse(JSON.stringify(schema)));
toast.success('Registration form saved successfully');
} catch (error) {
toast.error(error.response?.data?.detail?.message || 'Failed to save schema');
} finally {
setSaving(false);
}
};
// Reset to default
const handleReset = async () => {
if (!window.confirm('Are you sure you want to reset to the default registration form? This cannot be undone.')) {
return;
}
try {
await api.post('/admin/registration/schema/reset');
await fetchSchema();
toast.success('Registration form reset to default');
} catch (error) {
toast.error('Failed to reset schema');
}
};
// Get sorted steps
const sortedSteps = schema?.steps?.sort((a, b) => a.order - b.order) || [];
// Get current step
const currentStep = sortedSteps.find((s) => s.id === selectedStep);
const currentStepIndex = sortedSteps.findIndex((s) => s.id === selectedStep);
// Get sections for current step
const sortedSections = currentStep?.sections?.sort((a, b) => a.order - b.order) || [];
// Update schema
const updateSchema = useCallback((updater) => {
setSchema((prev) => {
const updated = typeof updater === 'function' ? updater(prev) : updater;
return { ...updated };
});
}, []);
// Add step
const handleAddStep = () => {
if (!newStepData.title.trim()) {
toast.error('Step title is required');
return;
}
const newStep = {
id: `step_${Date.now()}`,
title: newStepData.title,
description: newStepData.description,
order: sortedSteps.length + 1,
sections: [],
};
updateSchema((prev) => ({
...prev,
steps: [...prev.steps, newStep],
}));
setSelectedStep(newStep.id);
setAddStepDialogOpen(false);
setNewStepData({ title: '', description: '' });
};
// Delete step
const handleDeleteStep = (stepId) => {
const step = schema.steps.find((s) => s.id === stepId);
if (step?.sections?.some((sec) => sec.fields?.some((f) => f.is_fixed))) {
toast.error('Cannot delete step containing fixed fields');
return;
}
if (!window.confirm('Are you sure you want to delete this step?')) {
return;
}
updateSchema((prev) => ({
...prev,
steps: prev.steps
.filter((s) => s.id !== stepId)
.map((s, idx) => ({ ...s, order: idx + 1 })),
}));
if (selectedStep === stepId) {
setSelectedStep(sortedSteps[0]?.id);
}
};
// Move step
const handleMoveStep = (stepId, direction) => {
const stepIndex = sortedSteps.findIndex((s) => s.id === stepId);
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
if (newIndex < 0 || newIndex >= sortedSteps.length) return;
updateSchema((prev) => {
const steps = [...prev.steps];
const step = steps.find((s) => s.id === stepId);
const otherStep = sortedSteps[newIndex];
const otherStepInArray = steps.find((s) => s.id === otherStep.id);
const tempOrder = step.order;
step.order = otherStepInArray.order;
otherStepInArray.order = tempOrder;
return { ...prev, steps };
});
};
// Add section
const handleAddSection = () => {
if (!newSectionData.title.trim()) {
toast.error('Section title is required');
return;
}
const newSection = {
id: `section_${Date.now()}`,
title: newSectionData.title,
description: newSectionData.description,
order: sortedSections.length + 1,
fields: [],
};
updateSchema((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === selectedStep
? { ...s, sections: [...(s.sections || []), newSection] }
: s
),
}));
setSelectedSection(newSection.id);
setAddSectionDialogOpen(false);
setNewSectionData({ title: '', description: '' });
};
// Delete section
const handleDeleteSection = (sectionId) => {
const section = currentStep?.sections?.find((s) => s.id === sectionId);
if (section?.fields?.some((f) => f.is_fixed)) {
toast.error('Cannot delete section containing fixed fields');
return;
}
if (!window.confirm('Are you sure you want to delete this section?')) {
return;
}
updateSchema((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === selectedStep
? {
...s,
sections: s.sections
.filter((sec) => sec.id !== sectionId)
.map((sec, idx) => ({ ...sec, order: idx + 1 })),
}
: s
),
}));
if (selectedSection === sectionId) {
setSelectedSection(null);
}
};
// Add field
const handleAddField = (sectionId = selectedSection) => {
if (!sectionId) {
toast.error('Please select a section first');
return;
}
const section = currentStep?.sections?.find((s) => s.id === sectionId);
const fieldCount = section?.fields?.length || 0;
const newField = {
id: `field_${Date.now()}`,
type: newFieldType,
label: `New ${FIELD_TYPE_LABELS[newFieldType] || 'Field'}`,
required: false,
is_fixed: false,
width: 'full',
order: fieldCount + 1,
options: ['dropdown', 'radio', 'multiselect'].includes(newFieldType)
? [{ value: 'option1', label: 'Option 1' }]
: undefined,
};
updateSchema((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === selectedStep
? {
...s,
sections: s.sections.map((sec) =>
sec.id === sectionId
? { ...sec, fields: [...(sec.fields || []), newField] }
: sec
),
}
: s
),
}));
setSelectedSection(sectionId);
setSelectedField(newField.id);
setAddFieldDialogOpen(false);
};
// Delete field
const handleDeleteField = (sectionId, fieldId) => {
const section = currentStep?.sections?.find((s) => s.id === sectionId);
const field = section?.fields?.find((f) => f.id === fieldId);
if (field?.is_fixed) {
toast.error('Cannot delete fixed fields');
return;
}
if (!window.confirm('Are you sure you want to delete this field?')) {
return;
}
updateSchema((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === selectedStep
? {
...s,
sections: s.sections.map((sec) =>
sec.id === sectionId
? {
...sec,
fields: sec.fields
.filter((f) => f.id !== fieldId)
.map((f, idx) => ({ ...f, order: idx + 1 })),
}
: sec
),
}
: s
),
}));
if (selectedField === fieldId) {
setSelectedField(null);
}
};
// Update field
const handleUpdateField = (sectionId, fieldId, updates) => {
updateSchema((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === selectedStep
? {
...s,
sections: s.sections.map((sec) =>
sec.id === sectionId
? {
...sec,
fields: sec.fields.map((f) =>
f.id === fieldId ? { ...f, ...updates } : f
),
}
: sec
),
}
: s
),
}));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-brand-purple" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Registration Form Builder
</h1>
<p className="text-lg text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Customize the registration form steps, sections, and fields.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPreviewOpen(true)}>
<Eye className="h-4 w-4 mr-2" />
Preview
</Button>
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="h-4 w-4 mr-2" />
Reset
</Button>
<Button onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
</div>
</div>
{hasChanges && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-yellow-800 text-sm">
You have unsaved changes. Click "Save Changes" to apply them.
</div>
)}
{/* Main Builder Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-9">
<Card className="p-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Steps</h2>
<Button size="sm" variant="ghost" className="w-32" onClick={() => setAddStepDialogOpen(true)}>
<Plus className="h-4 w-4" />
Add Step
</Button>
</div>
<div className="flex flex-wrap items-end gap-2 border-b border-gray-200">
{sortedSteps.map((step, index) => (
<div
key={step.id}
className={`px-3 py-2 rounded-t-lg border cursor-pointer transition-colors ${selectedStep === step.id
? 'bg-white border-gray-300 border-b-white'
: 'bg-gray-50 border-transparent hover:bg-gray-100'
}`}
onClick={() => {
setSelectedStep(step.id);
setSelectedSection(null);
setSelectedField(null);
}}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="font-medium">Step {index + 1}</span>
{/* <span className="text-sm text-muted-foreground">{step.title || 'Untitled Step'}</span> */}
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-white"
onClick={(e) => {
e.stopPropagation();
handleDeleteStep(step.id);
}}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
{sortedSteps.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
No steps yet. Click "Add Step" to get started.
</div>
)}
</div>
{/* Sections for selected step */}
{currentStep && (
<div className="mt-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div>
<h2 className="text-xl font-semibold">
Step {currentStepIndex + 1}: {currentStep?.title || 'Untitled Step'}
</h2>
{currentStep.description && (
<p className="text-sm text-muted-foreground mt-1">{currentStep.description}</p>
)}
</div>
<Button size="sm" variant="ghost" onClick={() => setAddSectionDialogOpen(true)}>
<Plus className="h-4 w-4" />
Add Section
</Button>
</div>
<div className="space-y-4">
{sortedSections.map((section) => {
const sortedFields = section.fields?.sort((a, b) => a.order - b.order) || [];
return (
<div
key={section.id}
className={`p-6 rounded-lg border cursor-pointer transition-colors ${selectedSection === section.id
? 'border-brand-purple bg-brand-lavender/10'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => {
setSelectedSection(section.id);
setSelectedField(null);
}}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-medium">{section.title || 'Untitled Section'}</h3>
{section.description && (
<p className="text-sm text-muted-foreground mt-1">{section.description}</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
handleDeleteSection(section.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* Fields */}
<div className="mt-4 space-y-3">
{sortedFields.map((field) => {
const IconComponent = FIELD_TYPE_ICONS[field.type] || Type;
const options = field.options || [];
return (
<div
key={field.id}
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedField === field.id
? 'border-brand-purple bg-brand-lavender/5 ring-2 ring-brand-purple/20'
: 'border-gray-200 hover:border-gray-300'
} ${field.is_fixed ? 'bg-gray-50' : ''}`}
onClick={(e) => {
e.stopPropagation();
setSelectedSection(section.id);
setSelectedField(field.id);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-gray-400" />
<IconComponent className="h-4 w-4 text-gray-500" />
<div>
<span className="font-medium text-sm">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</span>
<span className="text-xs text-muted-foreground ml-2">
({FIELD_TYPE_LABELS[field.type] || field.type})
</span>
</div>
{field.is_fixed && (
<Lock className="h-3 w-3 text-gray-400" title="Fixed field - cannot be removed" />
)}
</div>
{!field.is_fixed && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
handleDeleteField(section.id, field.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
{selectedField === field.id && (
<div className="mt-4 border-t pt-4 space-y-4">
<div>
<Label className="text-xs text-muted-foreground">Field ID</Label>
<div className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{field.id}
</div>
</div>
<div>
<Label htmlFor={`field-label-${field.id}`}>Label</Label>
<Input
id={`field-label-${field.id}`}
value={field.label}
onChange={(e) => handleUpdateField(section.id, field.id, { label: e.target.value })}
disabled={field.is_fixed}
/>
</div>
<div>
<Label>Type</Label>
<Select
value={field.type}
onValueChange={(value) => handleUpdateField(section.id, field.id, { type: value })}
disabled={field.is_fixed}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FIELD_TYPE_LABELS).map(([type, label]) => (
<SelectItem key={type} value={type}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`field-required-${field.id}`}
checked={field.required}
onCheckedChange={(checked) =>
handleUpdateField(section.id, field.id, { required: checked })
}
disabled={field.is_fixed}
/>
<Label htmlFor={`field-required-${field.id}`}>Required</Label>
</div>
<div>
<Label>Width</Label>
<Select
value={field.width || 'full'}
onValueChange={(value) => handleUpdateField(section.id, field.id, { width: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">Full Width</SelectItem>
<SelectItem value="half">Half Width</SelectItem>
<SelectItem value="third">Third Width</SelectItem>
</SelectContent>
</Select>
</div>
{['text', 'email', 'phone', 'textarea'].includes(field.type) && (
<div>
<Label htmlFor={`field-placeholder-${field.id}`}>Placeholder</Label>
<Input
id={`field-placeholder-${field.id}`}
value={field.placeholder || ''}
onChange={(e) =>
handleUpdateField(section.id, field.id, { placeholder: e.target.value })
}
/>
</div>
)}
{['dropdown', 'radio', 'multiselect'].includes(field.type) && (
<div>
<Label>Options</Label>
<div className="space-y-2 mt-2">
{options.map((option, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
value={option.label}
onChange={(e) => {
const newOptions = [...options];
newOptions[idx] = {
...newOptions[idx],
label: e.target.value,
value: e.target.value.toLowerCase().replace(/\s+/g, '_'),
};
handleUpdateField(section.id, field.id, { options: newOptions });
}}
placeholder="Option label"
/>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => {
const newOptions = options.filter((_, i) => i !== idx);
handleUpdateField(section.id, field.id, { options: newOptions });
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newOptions = [
...options,
{ value: `option_${Date.now()}`, label: 'New Option' },
];
handleUpdateField(section.id, field.id, { options: newOptions });
}}
>
<Plus className="h-4 w-4 mr-2" />
Add Option
</Button>
</div>
</div>
)}
<div>
<Label htmlFor={`field-mapping-${field.id}`}>Database Mapping</Label>
<Input
id={`field-mapping-${field.id}`}
value={field.mapping || ''}
onChange={(e) =>
handleUpdateField(section.id, field.id, { mapping: e.target.value })
}
placeholder="Leave empty for custom data"
disabled={field.is_fixed}
/>
<p className="text-xs text-muted-foreground mt-1">
Maps to User model field. Empty = stored in custom_registration_data
</p>
</div>
{field.is_fixed && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-yellow-800 text-sm">
This is a fixed field and cannot be removed or have its core properties changed.
</div>
)}
</div>
)}
</div>
);
})}
{sortedFields.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
No fields in this section. Click "Add Field" to add one.
</div>
)}
</div>
<Button
size="sm"
className="w-full mt-4"
onClick={(e) => {
e.stopPropagation();
setSelectedSection(section.id);
setSelectedField(null);
setAddFieldDialogOpen(true);
}}
>
<Plus className="size-4 mr-2" />
Add Field
</Button>
</div>
);
})}
{sortedSections.length === 0 && (
<div className="text-center py-6 text-muted-foreground">
No sections yet. Click "Add Section" to get started.
</div>
)}
</div>
</div>
)}
</Card>
</div>
<div className="lg:col-span-3">
<Card className="p-6">
<div className="flex flex-col justify-between items-center mb-4">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" />
<h2 className="text-lg font-semibold">Conditional Rules</h2>
</div>
<Button size="sm" variant="outline" onClick={() => setConditionalDialogOpen(true)}>
<Settings className="h-4 w-4 mr-2" />
Manage Rules
</Button>
</div>
<div className="text-sm text-muted-foreground">
{schema?.conditional_rules?.length || 0} conditional rule(s) configured
</div>
</Card>
</div>
</div>
{/* Add Step Dialog */}
<Dialog open={addStepDialogOpen} onOpenChange={setAddStepDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Step</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="step-title">Step Title</Label>
<Input
id="step-title"
value={newStepData.title}
onChange={(e) => setNewStepData({ ...newStepData, title: e.target.value })}
placeholder="e.g., Additional Information"
/>
</div>
<div>
<Label htmlFor="step-description">Description (optional)</Label>
<Textarea
id="step-description"
value={newStepData.description}
onChange={(e) => setNewStepData({ ...newStepData, description: e.target.value })}
placeholder="Explain what this step is about"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddStepDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddStep}>Add Step</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Section Dialog */}
<Dialog open={addSectionDialogOpen} onOpenChange={setAddSectionDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Section</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="section-title">Section Title</Label>
<Input
id="section-title"
value={newSectionData.title}
onChange={(e) => setNewSectionData({ ...newSectionData, title: e.target.value })}
placeholder="e.g., Contact Information"
/>
</div>
<div>
<Label htmlFor="section-description">Description (optional)</Label>
<Textarea
id="section-description"
value={newSectionData.description}
onChange={(e) =>
setNewSectionData({ ...newSectionData, description: e.target.value })
}
placeholder="Explain what this section is for"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddSectionDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddSection}>Add Section</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Field Dialog */}
<Dialog open={addFieldDialogOpen} onOpenChange={setAddFieldDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Field</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>Field Type</Label>
<Select value={newFieldType} onValueChange={setNewFieldType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FIELD_TYPE_LABELS).map(([type, label]) => (
<SelectItem key={type} value={type}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddFieldDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddField}>Add Field</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Conditional Rules Dialog */}
<Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Conditional Rules</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
{(schema?.conditional_rules || []).map((rule, index) => (
<div key={rule.id} className="p-4 border rounded-lg space-y-3">
<div className="flex justify-between items-center">
<span className="font-medium">Rule {index + 1}</span>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover-text-background"
onClick={() => {
updateSchema((prev) => ({
...prev,
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
}));
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="text-sm text-muted-foreground">
When <span className="font-mono bg-gray-100 px-1">{rule.trigger_field}</span>{' '}
{rule.trigger_operator}{' '}
<span className="font-mono bg-gray-100 px-1">{String(rule.trigger_value)}</span>,{' '}
{rule.action} fields:{' '}
<span className="font-mono bg-gray-100 px-1">
{rule.target_fields?.join(', ')}
</span>
</div>
</div>
))}
{(schema?.conditional_rules || []).length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No conditional rules configured. Rules allow you to show or hide fields based on
other field values.
</div>
)}
<Button
variant="outline"
className="w-full"
onClick={() => {
const newRule = {
id: `rule_${Date.now()}`,
trigger_field: '',
trigger_operator: 'equals',
trigger_value: true,
action: 'show',
target_fields: [],
};
updateSchema((prev) => ({
...prev,
conditional_rules: [...(prev.conditional_rules || []), newRule],
}));
}}
>
<Plus className="h-4 w-4 mr-2" />
Add Rule
</Button>
</div>
<DialogFooter>
<Button onClick={() => setConditionalDialogOpen(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Preview Dialog */}
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Form Preview</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-muted-foreground mb-4">
This is a preview of how the registration form will appear to users.
</p>
{/* Simplified preview */}
<div className="space-y-6">
{sortedSteps.map((step) => (
<div key={step.id} className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2">{step.title}</h3>
{step.description && (
<p className="text-sm text-muted-foreground mb-4">{step.description}</p>
)}
{step.sections
?.sort((a, b) => a.order - b.order)
.map((section) => (
<div key={section.id} className="mb-4">
<h4 className="font-medium mb-2">{section.title}</h4>
<div className="grid grid-cols-2 gap-3">
{section.fields
?.sort((a, b) => a.order - b.order)
.map((field) => (
<div
key={field.id}
className={`${field.width === 'full'
? 'col-span-2'
: field.width === 'third'
? 'col-span-1'
: 'col-span-1'
}`}
>
<Label>
{field.label}
{field.required && (
<span className="text-red-500 ml-1">*</span>
)}
</Label>
<Input disabled placeholder={field.placeholder || field.label} />
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
</div>
<DialogFooter>
<Button onClick={() => setPreviewOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default AdminRegistrationBuilder;