'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 (
onTitleChange(step.id, e.target.value)}
className="h-8 text-sm font-medium"
placeholder={step.id}
/>
{step.id}
{step.enabled ? (
) : (
)}
onToggle(step.id, checked)}
disabled={isReview}
aria-label={`Toggle ${step.title || step.id}`}
/>
)
}
// ============================================================================
// 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 (
{option.label}
{option.description && (
{option.description}
)}
{option.value}
)
}
// ============================================================================
// 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(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(null)
const [optionForm, setOptionForm] = useState({
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>({
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,
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 (
{Array.from({ length: 5 }).map((_, i) => (
))}
)
}
// ============================================================================
// 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
)
// ============================================================================
// Render
// ============================================================================
return (
{/* Breadcrumb */}
Editions
/
{program?.name} {program?.year}
/
Apply Settings
{/* Header */}
Apply Wizard Settings
{isDirty && (
Unsaved changes
)}
Customize the application wizard for {program?.name} {program?.year}
{/* Template controls */}
{
if (value === '__mopc_classic__') {
setConfig(DEFAULT_WIZARD_CONFIG)
setIsDirty(true)
toast.success('Loaded preset: MOPC Classic')
return
}
const template = templates?.find((t: { id: string; name: string; config: unknown }) => t.id === value)
if (template) {
setConfig(template.config as WizardConfig)
setIsDirty(true)
toast.success(`Loaded template: ${template.name}`)
}
}}
>
MOPC Classic (Default)
{templates && templates.length > 0 && (
<>
{templates.map((t: { id: string; name: string }) => (
{t.name}
))}
>
)}
setSaveTemplateOpen(true)}
>
Save as Template
Reset to Defaults
{updateConfig.isPending ? (
) : (
)}
Save Changes
{/* Tabs */}
Steps
Dropdown Options
Features
Welcome
Custom Fields
{/* ================================================================ */}
{/* Tab 1: Steps */}
{/* ================================================================ */}
Wizard Steps
Configure and reorder the application wizard steps. Drag to
reorder, toggle visibility, and edit titles. The
"review" step cannot be disabled.
s.id)}
strategy={verticalListSortingStrategy}
>
{config.steps
.sort((a, b) => a.order - b.order)
.map((step) => (
))}
{/* ================================================================ */}
{/* Tab 2: Dropdown Options */}
{/* ================================================================ */}
{/* Competition Categories */}
Competition Categories
Categories applicants can select for their projects
openAddOptionDialog('categories')}
>
Add
handleOptionDragEnd('categories', e)}
>
`categories-${i}`
)}
strategy={verticalListSortingStrategy}
>
{(config.competitionCategories || []).map(
(option, index) => (
openEditOptionDialog('categories', index)
}
onDelete={() =>
handleDeleteOption('categories', index)
}
canDelete={canDeleteOption(
'categories',
option,
(config.competitionCategories || []).length
)}
/>
)
)}
{(config.competitionCategories || []).length === 0 && (
No categories defined. Add at least one.
)}
{/* Ocean Issues */}
Ocean Issues
Ocean-related issues applicants can select. The
"OTHER" option cannot be deleted.
openAddOptionDialog('oceanIssues')}
>
Add
handleOptionDragEnd('oceanIssues', e)}
>
`oceanIssues-${i}`
)}
strategy={verticalListSortingStrategy}
>
{(config.oceanIssues || []).map((option, index) => (
openEditOptionDialog('oceanIssues', index)
}
onDelete={() =>
handleDeleteOption('oceanIssues', index)
}
canDelete={canDeleteOption(
'oceanIssues',
option,
(config.oceanIssues || []).length
)}
/>
))}
{(config.oceanIssues || []).length === 0 && (
No ocean issues defined. Add at least one.
)}
{/* ================================================================ */}
{/* Tab 3: Features */}
{/* ================================================================ */}
Feature Toggles
Enable or disable optional features in the application wizard
Team Members
Allow applicants to add team members to their project
handleFeatureToggle('enableTeamMembers', v)
}
/>
Mentorship Requests
Allow applicants to request a mentor during the application
handleFeatureToggle('enableMentorship', v)
}
/>
WhatsApp Contact
Collect WhatsApp contact information from applicants
handleFeatureToggle('enableWhatsApp', v)
}
/>
Require Institution
Require applicants to provide their institution or
organization
handleFeatureToggle('requireInstitution', v)
}
/>
{/* ================================================================ */}
{/* Tab 4: Welcome */}
{/* ================================================================ */}
Welcome Message
Customize the welcome screen shown at the start of the
application wizard
{/* ================================================================ */}
{/* Tab 5: Custom Fields */}
{/* ================================================================ */}
Custom Fields
Add custom fields to specific wizard steps
Add Field
{(config.customFields || []).length === 0 ? (
No custom fields defined yet.
Add fields to collect additional information from applicants.
) : (
{WIZARD_STEP_IDS.filter(
(stepId) => customFieldsByStep[stepId]
).map((stepId) => (
Step: {config.steps.find((s) => s.id === stepId)?.title || stepId}
{customFieldsByStep[stepId].map((field) => (
{field.label}
{field.type}
{field.required && (
Required
)}
{field.helpText && (
{field.helpText}
)}
handleDeleteField(field.id)}
>
))}
))}
)}
{/* ================================================================ */}
{/* Option Add/Edit Dialog */}
{/* ================================================================ */}
{editingOptionIndex !== null ? 'Edit Option' : 'Add Option'}
{optionDialogSection === 'categories'
? 'Configure a competition category option'
: 'Configure an ocean issue option'}
setOptionDialogOpen(false)}
>
Cancel
{editingOptionIndex !== null ? 'Update' : 'Add'}
{/* ================================================================ */}
{/* Save Template Dialog */}
{/* ================================================================ */}
Save as Template
Save the current wizard configuration as a reusable template.
setSaveTemplateOpen(false)}
>
Cancel
{
createTemplate.mutate({
name: saveTemplateName,
config,
programId,
isGlobal: false,
})
}}
disabled={!saveTemplateName.trim() || createTemplate.isPending}
>
{createTemplate.isPending ? (
) : (
)}
Save Template
{/* ================================================================ */}
{/* Custom Field Dialog */}
{/* ================================================================ */}
Add Custom Field
Define a new field to collect additional information from
applicants
Type *
{
const isSelectType = v === 'select' || v === 'multiselect'
setFieldForm((prev) => ({
...prev,
type: v as CustomField['type'],
options: isSelectType ? (prev.options?.length ? prev.options : ['']) : [],
}))
}}
>
Text
Textarea
Number
Select
Multi-Select
Checkbox
Date
Label *
setFieldForm((prev) => ({ ...prev, label: e.target.value }))
}
placeholder="e.g., Project Budget"
maxLength={100}
/>
Placeholder
setFieldForm((prev) => ({
...prev,
placeholder: e.target.value,
}))
}
placeholder="e.g., Enter your project budget"
/>
Help Text
setFieldForm((prev) => ({
...prev,
helpText: e.target.value,
}))
}
placeholder="e.g., Estimated budget in EUR"
/>
{/* Options editor for select/multiselect */}
{(fieldForm.type === 'select' || fieldForm.type === 'multiselect') && (
Options *
Add at least 2 options. One per line.
{(fieldForm.options || ['']).map((opt, idx) => (
{
const updated = [...(fieldForm.options || [''])]
updated[idx] = e.target.value
setFieldForm((prev) => ({ ...prev, options: updated }))
}}
placeholder={`Option ${idx + 1}`}
/>
{(fieldForm.options || []).length > 1 && (
{
const updated = (fieldForm.options || []).filter((_, i) => i !== idx)
setFieldForm((prev) => ({ ...prev, options: updated }))
}}
>
)}
))}
{
setFieldForm((prev) => ({
...prev,
options: [...(prev.options || []), ''],
}))
}}
>
Add Option
)}
Step *
setFieldForm((prev) => ({
...prev,
stepId: v as WizardStepId,
}))
}
>
{config.steps
.filter((s) => s.enabled)
.sort((a, b) => a.order - b.order)
.map((step) => (
{step.title || step.id}
))}
Required
Applicants must fill out this field
setFieldForm((prev) => ({ ...prev, required: v }))
}
/>
setFieldDialogOpen(false)}
>
Cancel
Add Field
)
}