Files
MOPC-Portal/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx

1474 lines
49 KiB
TypeScript
Raw Normal View History

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import type {
WizardConfig,
WizardStep,
DropdownOption,
CustomField,
WizardStepId,
} from '@/types/wizard-config'
import { DEFAULT_WIZARD_CONFIG, WIZARD_STEP_IDS } from '@/types/wizard-config'
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 { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
GripVertical,
Eye,
EyeOff,
Save,
Loader2,
Plus,
Pencil,
Trash2,
RotateCcw,
Download,
Upload,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
// ============================================================================
// Sortable Step Row
// ============================================================================
function SortableStepRow({
step,
onToggle,
onTitleChange,
}: {
step: WizardStep
onToggle: (id: WizardStepId, enabled: boolean) => void
onTitleChange: (id: WizardStepId, title: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const isReview = step.id === 'review'
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-3 rounded-lg border bg-card p-3',
isDragging && 'opacity-50 shadow-lg'
)}
>
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<Input
value={step.title || ''}
onChange={(e) => onTitleChange(step.id, e.target.value)}
className="h-8 text-sm font-medium"
placeholder={step.id}
/>
</div>
<Badge variant="secondary" className="text-xs shrink-0">
{step.id}
</Badge>
{step.enabled ? (
<Eye className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<EyeOff className="h-4 w-4 text-muted-foreground/40 shrink-0" />
)}
<Switch
checked={step.enabled}
onCheckedChange={(checked) => onToggle(step.id, checked)}
disabled={isReview}
aria-label={`Toggle ${step.title || step.id}`}
/>
</div>
)
}
// ============================================================================
// Sortable Dropdown Option Row
// ============================================================================
function SortableOptionRow({
option,
onEdit,
onDelete,
canDelete,
}: {
option: DropdownOption & { _id: string }
onEdit: () => void
onDelete: () => void
canDelete: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: option._id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-3 rounded-lg border bg-card p-3',
isDragging && 'opacity-50 shadow-lg'
)}
>
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{option.label}</p>
{option.description && (
<p className="text-xs text-muted-foreground truncate">
{option.description}
</p>
)}
</div>
<Badge variant="outline" className="text-xs shrink-0">
{option.value}
</Badge>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={onDelete}
disabled={!canDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
}
// ============================================================================
// Main Page Component
// ============================================================================
export default function ApplySettingsPage() {
const params = useParams<{ id: string }>()
const programId = params.id
// --- Queries ---
const { data: program, isLoading: programLoading } = trpc.program.get.useQuery(
{ id: programId },
{ enabled: !!programId }
)
const { data: serverConfig, isLoading: configLoading } =
trpc.program.getWizardConfig.useQuery(
{ programId },
{ enabled: !!programId }
)
const { data: templates } = trpc.wizardTemplate.list.useQuery(
{ programId },
{ enabled: !!programId }
)
// --- Mutations ---
const createTemplate = trpc.wizardTemplate.create.useMutation({
onSuccess: () => {
toast.success('Template saved')
setSaveTemplateOpen(false)
setSaveTemplateName('')
},
onError: (error) => toast.error(error.message),
})
const updateConfig = trpc.program.updateWizardConfig.useMutation({
onSuccess: () => {
toast.success('Settings saved successfully')
setIsDirty(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to save settings')
},
})
// --- Local State ---
const [config, setConfig] = useState<WizardConfig>(DEFAULT_WIZARD_CONFIG)
const [isDirty, setIsDirty] = useState(false)
const [initialized, setInitialized] = useState(false)
// Dialog states
const [optionDialogOpen, setOptionDialogOpen] = useState(false)
const [optionDialogSection, setOptionDialogSection] = useState<
'categories' | 'oceanIssues'
>('categories')
const [editingOptionIndex, setEditingOptionIndex] = useState<number | null>(null)
const [optionForm, setOptionForm] = useState<DropdownOption>({
value: '',
label: '',
description: '',
icon: '',
})
// Template dialog
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
const [saveTemplateName, setSaveTemplateName] = useState('')
// Custom field dialog
const [fieldDialogOpen, setFieldDialogOpen] = useState(false)
const [fieldForm, setFieldForm] = useState<Omit<CustomField, 'id' | 'order'>>({
type: 'text',
label: '',
placeholder: '',
helpText: '',
required: false,
stepId: 'additional',
})
// Initialize local state from server data
useEffect(() => {
if (serverConfig && !initialized) {
setConfig(serverConfig)
setInitialized(true)
}
}, [serverConfig, initialized])
// --- DnD Sensors ---
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// --- Helper: update config and mark dirty ---
const updateLocalConfig = useCallback(
(updater: (prev: WizardConfig) => WizardConfig) => {
setConfig((prev) => updater(prev))
setIsDirty(true)
},
[]
)
// ============================================================================
// Tab 1: Steps handlers
// ============================================================================
function handleStepDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
updateLocalConfig((prev) => {
const steps = [...prev.steps]
const oldIndex = steps.findIndex((s) => s.id === active.id)
const newIndex = steps.findIndex((s) => s.id === over.id)
const reordered = arrayMove(steps, oldIndex, newIndex).map((s, i) => ({
...s,
order: i,
}))
return { ...prev, steps: reordered }
})
}
function handleStepToggle(id: WizardStepId, enabled: boolean) {
if (id === 'review') return
updateLocalConfig((prev) => ({
...prev,
steps: prev.steps.map((s) => (s.id === id ? { ...s, enabled } : s)),
}))
}
function handleStepTitleChange(id: WizardStepId, title: string) {
updateLocalConfig((prev) => ({
...prev,
steps: prev.steps.map((s) => (s.id === id ? { ...s, title } : s)),
}))
}
// ============================================================================
// Tab 2: Dropdown Options handlers
// ============================================================================
function getOptions(section: 'categories' | 'oceanIssues'): DropdownOption[] {
return section === 'categories'
? config.competitionCategories || []
: config.oceanIssues || []
}
function setOptions(
section: 'categories' | 'oceanIssues',
options: DropdownOption[]
) {
updateLocalConfig((prev) => ({
...prev,
...(section === 'categories'
? { competitionCategories: options }
: { oceanIssues: options }),
}))
}
function handleOptionDragEnd(
section: 'categories' | 'oceanIssues',
event: DragEndEvent
) {
const { active, over } = event
if (!over || active.id === over.id) return
const options = getOptions(section)
const oldIndex = options.findIndex(
(_, i) => `${section}-${i}` === active.id
)
const newIndex = options.findIndex(
(_, i) => `${section}-${i}` === over.id
)
if (oldIndex === -1 || newIndex === -1) return
setOptions(section, arrayMove(options, oldIndex, newIndex))
}
function openAddOptionDialog(section: 'categories' | 'oceanIssues') {
setOptionDialogSection(section)
setEditingOptionIndex(null)
setOptionForm({ value: '', label: '', description: '', icon: '' })
setOptionDialogOpen(true)
}
function openEditOptionDialog(
section: 'categories' | 'oceanIssues',
index: number
) {
const options = getOptions(section)
const option = options[index]
setOptionDialogSection(section)
setEditingOptionIndex(index)
setOptionForm({
value: option.value,
label: option.label,
description: option.description || '',
icon: option.icon || '',
})
setOptionDialogOpen(true)
}
function handleSaveOption() {
if (!optionForm.value.trim() || !optionForm.label.trim()) {
toast.error('Value and Label are required')
return
}
const options = getOptions(optionDialogSection)
// Clean up optional fields
const cleanOption: DropdownOption = {
value: optionForm.value.trim(),
label: optionForm.label.trim(),
...(optionForm.description?.trim()
? { description: optionForm.description.trim() }
: {}),
...(optionForm.icon?.trim() ? { icon: optionForm.icon.trim() } : {}),
}
if (editingOptionIndex !== null) {
const updated = [...options]
updated[editingOptionIndex] = cleanOption
setOptions(optionDialogSection, updated)
} else {
setOptions(optionDialogSection, [...options, cleanOption])
}
setOptionDialogOpen(false)
}
function handleDeleteOption(
section: 'categories' | 'oceanIssues',
index: number
) {
const options = getOptions(section)
if (options.length <= 1) {
toast.error('At least one option is required')
return
}
const option = options[index]
if (section === 'oceanIssues' && option.value === 'OTHER') {
toast.error('The "OTHER" option cannot be deleted')
return
}
setOptions(
section,
options.filter((_, i) => i !== index)
)
}
function canDeleteOption(
section: 'categories' | 'oceanIssues',
option: DropdownOption,
totalCount: number
): boolean {
if (totalCount <= 1) return false
if (section === 'oceanIssues' && option.value === 'OTHER') return false
return true
}
// ============================================================================
// Tab 3: Features handlers
// ============================================================================
function handleFeatureToggle(
key: keyof NonNullable<WizardConfig['features']>,
value: boolean
) {
updateLocalConfig((prev) => ({
...prev,
features: {
...prev.features,
[key]: value,
},
}))
}
// ============================================================================
// Tab 4: Welcome handlers
// ============================================================================
function handleWelcomeChange(
field: 'title' | 'description',
value: string
) {
updateLocalConfig((prev) => ({
...prev,
welcomeMessage: {
...prev.welcomeMessage,
[field]: value || undefined,
},
}))
}
// ============================================================================
// Tab 5: Custom Fields handlers
// ============================================================================
function openAddFieldDialog() {
setFieldForm({
type: 'text',
label: '',
placeholder: '',
helpText: '',
required: false,
stepId: 'additional',
options: [],
})
setFieldDialogOpen(true)
}
function handleSaveField() {
if (!fieldForm.label.trim()) {
toast.error('Field label is required')
return
}
const needsOptions = fieldForm.type === 'select' || fieldForm.type === 'multiselect'
if (needsOptions && (!fieldForm.options || fieldForm.options.length < 2)) {
toast.error('Select fields require at least 2 options')
return
}
const newField: CustomField = {
id: `custom_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
type: fieldForm.type,
label: fieldForm.label.trim(),
placeholder: fieldForm.placeholder?.trim() || undefined,
helpText: fieldForm.helpText?.trim() || undefined,
required: fieldForm.required,
stepId: fieldForm.stepId,
order: (config.customFields || []).length,
options: needsOptions ? fieldForm.options?.filter(Boolean) : undefined,
}
updateLocalConfig((prev) => ({
...prev,
customFields: [...(prev.customFields || []), newField],
}))
setFieldDialogOpen(false)
}
function handleDeleteField(fieldId: string) {
updateLocalConfig((prev) => ({
...prev,
customFields: (prev.customFields || []).filter((f) => f.id !== fieldId),
}))
}
// ============================================================================
// Save & Reset
// ============================================================================
function handleSave() {
updateConfig.mutate({ programId, wizardConfig: config })
}
function handleReset() {
setConfig(DEFAULT_WIZARD_CONFIG)
setIsDirty(true)
}
// ============================================================================
// Loading State
// ============================================================================
if (programLoading || configLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-12 w-full" />
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
)
}
// ============================================================================
// Grouped custom fields for Tab 5
// ============================================================================
const customFieldsByStep = (config.customFields || []).reduce(
(acc, field) => {
const stepId = field.stepId || 'additional'
if (!acc[stepId]) acc[stepId] = []
acc[stepId].push(field)
return acc
},
{} as Record<string, CustomField[]>
)
// ============================================================================
// Render
// ============================================================================
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Link href="/admin/programs" className="hover:text-foreground transition-colors">
Editions
</Link>
<span>/</span>
<Link
href={`/admin/programs/${programId}`}
className="hover:text-foreground transition-colors"
>
{program?.name} {program?.year}
</Link>
<span>/</span>
<span className="text-foreground">Apply Settings</span>
</div>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
Apply Wizard Settings
</h1>
{isDirty && (
<Badge variant="outline" className="text-amber-600 border-amber-300 bg-amber-50">
Unsaved changes
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1">
Customize the application wizard for {program?.name} {program?.year}
</p>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
{/* Template controls */}
<Select
onValueChange={(value) => {
if (value === '__mopc_classic__') {
setConfig(DEFAULT_WIZARD_CONFIG)
setIsDirty(true)
toast.success('Loaded preset: MOPC Classic')
return
}
const template = templates?.find((t) => t.id === value)
if (template) {
setConfig(template.config as WizardConfig)
setIsDirty(true)
toast.success(`Loaded template: ${template.name}`)
}
}}
>
<SelectTrigger className="w-[200px]">
<Download className="mr-2 h-4 w-4" />
<SelectValue placeholder="Load template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__mopc_classic__">
MOPC Classic (Default)
</SelectItem>
{templates && templates.length > 0 && (
<>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => setSaveTemplateOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Save as Template
</Button>
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset to Defaults
</Button>
<Button onClick={handleSave} disabled={updateConfig.isPending || !isDirty}>
{updateConfig.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</div>
<Separator />
{/* Tabs */}
<Tabs defaultValue="steps" className="space-y-6">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="steps">Steps</TabsTrigger>
<TabsTrigger value="dropdowns">Dropdown Options</TabsTrigger>
<TabsTrigger value="features">Features</TabsTrigger>
<TabsTrigger value="welcome">Welcome</TabsTrigger>
<TabsTrigger value="fields">Custom Fields</TabsTrigger>
</TabsList>
{/* ================================================================ */}
{/* Tab 1: Steps */}
{/* ================================================================ */}
<TabsContent value="steps">
<Card>
<CardHeader>
<CardTitle>Wizard Steps</CardTitle>
<CardDescription>
Configure and reorder the application wizard steps. Drag to
reorder, toggle visibility, and edit titles. The
&quot;review&quot; step cannot be disabled.
</CardDescription>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleStepDragEnd}
>
<SortableContext
items={config.steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{config.steps
.sort((a, b) => a.order - b.order)
.map((step) => (
<SortableStepRow
key={step.id}
step={step}
onToggle={handleStepToggle}
onTitleChange={handleStepTitleChange}
/>
))}
</div>
</SortableContext>
</DndContext>
</CardContent>
</Card>
</TabsContent>
{/* ================================================================ */}
{/* Tab 2: Dropdown Options */}
{/* ================================================================ */}
<TabsContent value="dropdowns">
<div className="space-y-6">
{/* Competition Categories */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Competition Categories</CardTitle>
<CardDescription>
Categories applicants can select for their projects
</CardDescription>
</div>
<Button
size="sm"
onClick={() => openAddOptionDialog('categories')}
>
<Plus className="mr-2 h-4 w-4" />
Add
</Button>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleOptionDragEnd('categories', e)}
>
<SortableContext
items={(config.competitionCategories || []).map(
(_, i) => `categories-${i}`
)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{(config.competitionCategories || []).map(
(option, index) => (
<SortableOptionRow
key={`categories-${index}`}
option={{
...option,
_id: `categories-${index}`,
}}
onEdit={() =>
openEditOptionDialog('categories', index)
}
onDelete={() =>
handleDeleteOption('categories', index)
}
canDelete={canDeleteOption(
'categories',
option,
(config.competitionCategories || []).length
)}
/>
)
)}
</div>
</SortableContext>
</DndContext>
{(config.competitionCategories || []).length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No categories defined. Add at least one.
</p>
)}
</CardContent>
</Card>
{/* Ocean Issues */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Ocean Issues</CardTitle>
<CardDescription>
Ocean-related issues applicants can select. The
&quot;OTHER&quot; option cannot be deleted.
</CardDescription>
</div>
<Button
size="sm"
onClick={() => openAddOptionDialog('oceanIssues')}
>
<Plus className="mr-2 h-4 w-4" />
Add
</Button>
</CardHeader>
<CardContent>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => handleOptionDragEnd('oceanIssues', e)}
>
<SortableContext
items={(config.oceanIssues || []).map(
(_, i) => `oceanIssues-${i}`
)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{(config.oceanIssues || []).map((option, index) => (
<SortableOptionRow
key={`oceanIssues-${index}`}
option={{
...option,
_id: `oceanIssues-${index}`,
}}
onEdit={() =>
openEditOptionDialog('oceanIssues', index)
}
onDelete={() =>
handleDeleteOption('oceanIssues', index)
}
canDelete={canDeleteOption(
'oceanIssues',
option,
(config.oceanIssues || []).length
)}
/>
))}
</div>
</SortableContext>
</DndContext>
{(config.oceanIssues || []).length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No ocean issues defined. Add at least one.
</p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* ================================================================ */}
{/* Tab 3: Features */}
{/* ================================================================ */}
<TabsContent value="features">
<Card>
<CardHeader>
<CardTitle>Feature Toggles</CardTitle>
<CardDescription>
Enable or disable optional features in the application wizard
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">Team Members</Label>
<p className="text-sm text-muted-foreground">
Allow applicants to add team members to their project
</p>
</div>
<Switch
checked={config.features?.enableTeamMembers ?? true}
onCheckedChange={(v) =>
handleFeatureToggle('enableTeamMembers', v)
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">
Mentorship Requests
</Label>
<p className="text-sm text-muted-foreground">
Allow applicants to request a mentor during the application
</p>
</div>
<Switch
checked={config.features?.enableMentorship ?? true}
onCheckedChange={(v) =>
handleFeatureToggle('enableMentorship', v)
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">
WhatsApp Contact
</Label>
<p className="text-sm text-muted-foreground">
Collect WhatsApp contact information from applicants
</p>
</div>
<Switch
checked={config.features?.enableWhatsApp ?? false}
onCheckedChange={(v) =>
handleFeatureToggle('enableWhatsApp', v)
}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">
Require Institution
</Label>
<p className="text-sm text-muted-foreground">
Require applicants to provide their institution or
organization
</p>
</div>
<Switch
checked={config.features?.requireInstitution ?? false}
onCheckedChange={(v) =>
handleFeatureToggle('requireInstitution', v)
}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ================================================================ */}
{/* Tab 4: Welcome */}
{/* ================================================================ */}
<TabsContent value="welcome">
<Card>
<CardHeader>
<CardTitle>Welcome Message</CardTitle>
<CardDescription>
Customize the welcome screen shown at the start of the
application wizard
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="welcome-title">Title</Label>
<Input
id="welcome-title"
value={config.welcomeMessage?.title || ''}
onChange={(e) => handleWelcomeChange('title', e.target.value)}
placeholder="Welcome to the application process"
maxLength={200}
/>
<p className="text-xs text-muted-foreground">
Optional. Max 200 characters.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="welcome-description">Description</Label>
<Textarea
id="welcome-description"
value={config.welcomeMessage?.description || ''}
onChange={(e) =>
handleWelcomeChange('description', e.target.value)
}
placeholder="Please fill out the following form to submit your project for consideration..."
rows={4}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
Optional. Max 1000 characters.
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ================================================================ */}
{/* Tab 5: Custom Fields */}
{/* ================================================================ */}
<TabsContent value="fields">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Custom Fields</CardTitle>
<CardDescription>
Add custom fields to specific wizard steps
</CardDescription>
</div>
<Button size="sm" onClick={openAddFieldDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</CardHeader>
<CardContent>
{(config.customFields || []).length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
<p>No custom fields defined yet.</p>
<p className="text-sm mt-1">
Add fields to collect additional information from applicants.
</p>
</div>
) : (
<div className="space-y-6">
{WIZARD_STEP_IDS.filter(
(stepId) => customFieldsByStep[stepId]
).map((stepId) => (
<div key={stepId}>
<h4 className="text-sm font-semibold mb-2 capitalize">
Step: {config.steps.find((s) => s.id === stepId)?.title || stepId}
</h4>
<div className="space-y-2">
{customFieldsByStep[stepId].map((field) => (
<div
key={field.id}
className="flex items-center gap-3 rounded-lg border bg-card p-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{field.label}
</p>
<Badge variant="outline" className="text-xs">
{field.type}
</Badge>
{field.required && (
<Badge
variant="secondary"
className="text-xs"
>
Required
</Badge>
)}
</div>
{field.helpText && (
<p className="text-xs text-muted-foreground mt-0.5">
{field.helpText}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteField(field.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* ================================================================ */}
{/* Option Add/Edit Dialog */}
{/* ================================================================ */}
<Dialog open={optionDialogOpen} onOpenChange={setOptionDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingOptionIndex !== null ? 'Edit Option' : 'Add Option'}
</DialogTitle>
<DialogDescription>
{optionDialogSection === 'categories'
? 'Configure a competition category option'
: 'Configure an ocean issue option'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="option-value">Value *</Label>
<Input
id="option-value"
value={optionForm.value}
onChange={(e) =>
setOptionForm((prev) => ({
...prev,
value: e.target.value.toUpperCase().replace(/\s+/g, '_'),
}))
}
placeholder="e.g., MARINE_TECH"
disabled={editingOptionIndex !== null}
/>
<p className="text-xs text-muted-foreground">
Unique identifier (auto-formatted to SCREAMING_SNAKE_CASE).
Cannot be changed after creation.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="option-label">Label *</Label>
<Input
id="option-label"
value={optionForm.label}
onChange={(e) =>
setOptionForm((prev) => ({ ...prev, label: e.target.value }))
}
placeholder="e.g., Marine Technology"
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="option-description">Description</Label>
<Textarea
id="option-description"
value={optionForm.description || ''}
onChange={(e) =>
setOptionForm((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Optional description for this option"
rows={2}
maxLength={300}
/>
</div>
<div className="space-y-2">
<Label htmlFor="option-icon">Icon</Label>
<Input
id="option-icon"
value={optionForm.icon || ''}
onChange={(e) =>
setOptionForm((prev) => ({ ...prev, icon: e.target.value }))
}
placeholder="e.g., Rocket (Lucide icon name)"
/>
<p className="text-xs text-muted-foreground">
Optional Lucide icon name to display alongside this option.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOptionDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleSaveOption}
disabled={!optionForm.value.trim() || !optionForm.label.trim()}
>
{editingOptionIndex !== null ? 'Update' : 'Add'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ================================================================ */}
{/* Save Template Dialog */}
{/* ================================================================ */}
<Dialog open={saveTemplateOpen} onOpenChange={setSaveTemplateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save as Template</DialogTitle>
<DialogDescription>
Save the current wizard configuration as a reusable template.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="template-name">Template Name *</Label>
<Input
id="template-name"
value={saveTemplateName}
onChange={(e) => setSaveTemplateName(e.target.value)}
placeholder="e.g., MOPC 2026 Standard"
maxLength={100}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSaveTemplateOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => {
createTemplate.mutate({
name: saveTemplateName,
config,
programId,
isGlobal: false,
})
}}
disabled={!saveTemplateName.trim() || createTemplate.isPending}
>
{createTemplate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ================================================================ */}
{/* Custom Field Dialog */}
{/* ================================================================ */}
<Dialog open={fieldDialogOpen} onOpenChange={setFieldDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Custom Field</DialogTitle>
<DialogDescription>
Define a new field to collect additional information from
applicants
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="field-type">Type *</Label>
<Select
value={fieldForm.type}
onValueChange={(v) => {
const isSelectType = v === 'select' || v === 'multiselect'
setFieldForm((prev) => ({
...prev,
type: v as CustomField['type'],
options: isSelectType ? (prev.options?.length ? prev.options : ['']) : [],
}))
}}
>
<SelectTrigger id="field-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="textarea">Textarea</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="multiselect">Multi-Select</SelectItem>
<SelectItem value="checkbox">Checkbox</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="field-label">Label *</Label>
<Input
id="field-label"
value={fieldForm.label}
onChange={(e) =>
setFieldForm((prev) => ({ ...prev, label: e.target.value }))
}
placeholder="e.g., Project Budget"
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="field-placeholder">Placeholder</Label>
<Input
id="field-placeholder"
value={fieldForm.placeholder || ''}
onChange={(e) =>
setFieldForm((prev) => ({
...prev,
placeholder: e.target.value,
}))
}
placeholder="e.g., Enter your project budget"
/>
</div>
<div className="space-y-2">
<Label htmlFor="field-help">Help Text</Label>
<Input
id="field-help"
value={fieldForm.helpText || ''}
onChange={(e) =>
setFieldForm((prev) => ({
...prev,
helpText: e.target.value,
}))
}
placeholder="e.g., Estimated budget in EUR"
/>
</div>
{/* Options editor for select/multiselect */}
{(fieldForm.type === 'select' || fieldForm.type === 'multiselect') && (
<div className="space-y-2">
<Label>Options *</Label>
<p className="text-xs text-muted-foreground">
Add at least 2 options. One per line.
</p>
<div className="space-y-2">
{(fieldForm.options || ['']).map((opt, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
value={opt}
onChange={(e) => {
const updated = [...(fieldForm.options || [''])]
updated[idx] = e.target.value
setFieldForm((prev) => ({ ...prev, options: updated }))
}}
placeholder={`Option ${idx + 1}`}
/>
{(fieldForm.options || []).length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
const updated = (fieldForm.options || []).filter((_, i) => i !== idx)
setFieldForm((prev) => ({ ...prev, options: updated }))
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setFieldForm((prev) => ({
...prev,
options: [...(prev.options || []), ''],
}))
}}
>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Option
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="field-step">Step *</Label>
<Select
value={fieldForm.stepId}
onValueChange={(v) =>
setFieldForm((prev) => ({
...prev,
stepId: v as WizardStepId,
}))
}
>
<SelectTrigger id="field-step">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.steps
.filter((s) => s.enabled)
.sort((a, b) => a.order - b.order)
.map((step) => (
<SelectItem key={step.id} value={step.id}>
{step.title || step.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Required</Label>
<p className="text-sm text-muted-foreground">
Applicants must fill out this field
</p>
</div>
<Switch
checked={fieldForm.required}
onCheckedChange={(v) =>
setFieldForm((prev) => ({ ...prev, required: v }))
}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setFieldDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleSaveField}
disabled={!fieldForm.label.trim()}
>
Add Field
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}