'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, ExternalLink, } 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 utils = trpc.useUtils() const createTemplate = trpc.wizardTemplate.create.useMutation({ onSuccess: () => { utils.wizardTemplate.list.invalidate() toast.success('Template saved') setSaveTemplateOpen(false) setSaveTemplateName('') }, onError: (error) => toast.error(error.message), }) const updateConfig = trpc.program.updateWizardConfig.useMutation({ onSuccess: () => { utils.program.get.invalidate({ id: programId }) 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 Page
{/* Header */}

Apply Wizard Settings

{isDirty && ( Unsaved changes )}

Customize the application wizard for {program?.name} {program?.year}

{/* View public apply page */} {program?.slug && ( )} {/* Template controls */}
{/* 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
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.
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

Allow applicants to add team members to their project

handleFeatureToggle('enableTeamMembers', v) } />

Allow applicants to request a mentor during the application

handleFeatureToggle('enableMentorship', v) } />

Collect WhatsApp contact information from applicants

handleFeatureToggle('enableWhatsApp', v) } />

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
handleWelcomeChange('title', e.target.value)} placeholder="Welcome to the application process" maxLength={200} />

Optional. Max 200 characters.