- Create wizard config types, utilities, and defaults (wizard-config.ts) - Add admin apply settings page with drag-and-drop step ordering, dropdown option management, feature toggles, welcome message customization, and custom field builder with select/multiselect options editor - Build dynamic apply wizard component with animated step transitions, mobile-first responsive design, and config-driven form validation - Update step components to accept dynamic config (categories, ocean issues, field visibility, feature flags) - Replace hardcoded enum validation with string-based validation for admin-configurable dropdown values, with safe enum casting at storage layer - Add wizard template system (model, router, admin UI) with built-in MOPC Classic preset - Add program wizard config CRUD procedures to program router - Update application router getConfig to return wizardConfig, submit handler to store custom field data in metadataJson - Add edition-based apply page, project pool page, and supporting routers - Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea), safe area insets for notched phones, buildStepsArray field visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
4.3 KiB
TypeScript
138 lines
4.3 KiB
TypeScript
import {
|
|
type WizardConfig,
|
|
type WizardStep,
|
|
type WizardFieldConfig,
|
|
type WizardStepId,
|
|
DEFAULT_WIZARD_CONFIG,
|
|
wizardConfigSchema,
|
|
} from '@/types/wizard-config'
|
|
|
|
/**
|
|
* Parse wizard config from Program.settingsJson with fallback to defaults.
|
|
* Used by both backend (application router) and frontend (apply pages).
|
|
*/
|
|
export function parseWizardConfig(settingsJson: unknown): WizardConfig {
|
|
if (!settingsJson || typeof settingsJson !== 'object') {
|
|
return DEFAULT_WIZARD_CONFIG
|
|
}
|
|
const settings = settingsJson as Record<string, unknown>
|
|
if (!settings.wizardConfig) {
|
|
return DEFAULT_WIZARD_CONFIG
|
|
}
|
|
try {
|
|
const parsed = wizardConfigSchema.parse(settings.wizardConfig)
|
|
return mergeWizardConfig(parsed)
|
|
} catch {
|
|
console.error('[WizardConfig] Invalid config, using defaults')
|
|
return DEFAULT_WIZARD_CONFIG
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get enabled steps sorted by order.
|
|
*/
|
|
export function getActiveSteps(config: WizardConfig): WizardStep[] {
|
|
return config.steps.filter((step) => step.enabled).sort((a, b) => a.order - b.order)
|
|
}
|
|
|
|
/**
|
|
* Evaluate conditional step visibility based on current form values.
|
|
* Returns only steps whose conditions are met (or have no condition).
|
|
*/
|
|
export function getVisibleSteps(
|
|
config: WizardConfig,
|
|
formValues: Record<string, unknown>
|
|
): WizardStep[] {
|
|
return getActiveSteps(config).filter((step) => {
|
|
if (!step.conditionalOn) return true
|
|
const { field, operator, value } = step.conditionalOn
|
|
const fieldValue = formValues[field]
|
|
switch (operator) {
|
|
case 'equals':
|
|
return fieldValue === value
|
|
case 'notEquals':
|
|
return fieldValue !== value
|
|
case 'in':
|
|
return Array.isArray(value) && value.includes(String(fieldValue))
|
|
case 'notIn':
|
|
return Array.isArray(value) && !value.includes(String(fieldValue))
|
|
default:
|
|
return true
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get field configuration with sensible defaults.
|
|
*/
|
|
export function getFieldConfig(config: WizardConfig, fieldName: string): WizardFieldConfig {
|
|
return config.fields[fieldName] ?? { required: true, visible: true }
|
|
}
|
|
|
|
/**
|
|
* Check if a specific field should be visible based on config.
|
|
*/
|
|
export function isFieldVisible(config: WizardConfig, fieldName: string): boolean {
|
|
const fieldConfig = config.fields[fieldName]
|
|
return fieldConfig?.visible !== false
|
|
}
|
|
|
|
/**
|
|
* Check if a specific field is required based on config.
|
|
*/
|
|
export function isFieldRequired(config: WizardConfig, fieldName: string): boolean {
|
|
const fieldConfig = config.fields[fieldName]
|
|
return fieldConfig?.required !== false
|
|
}
|
|
|
|
/**
|
|
* Get custom fields assigned to a specific step, sorted by order.
|
|
*/
|
|
export function getCustomFieldsForStep(
|
|
config: WizardConfig,
|
|
stepId: WizardStepId
|
|
): NonNullable<WizardConfig['customFields']> {
|
|
return (config.customFields ?? [])
|
|
.filter((f) => f.stepId === stepId)
|
|
.sort((a, b) => a.order - b.order)
|
|
}
|
|
|
|
/**
|
|
* Merge partial config with defaults. Ensures all arrays/objects exist.
|
|
*/
|
|
export function mergeWizardConfig(partial: Partial<WizardConfig>): WizardConfig {
|
|
return {
|
|
steps: partial.steps?.length ? partial.steps : DEFAULT_WIZARD_CONFIG.steps,
|
|
fields: partial.fields ?? DEFAULT_WIZARD_CONFIG.fields,
|
|
competitionCategories:
|
|
partial.competitionCategories ?? DEFAULT_WIZARD_CONFIG.competitionCategories,
|
|
oceanIssues: partial.oceanIssues ?? DEFAULT_WIZARD_CONFIG.oceanIssues,
|
|
features: { ...DEFAULT_WIZARD_CONFIG.features, ...partial.features },
|
|
welcomeMessage: partial.welcomeMessage,
|
|
customFields: partial.customFields ?? [],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the STEPS array for the wizard from config (format used by apply pages).
|
|
* Maps step IDs to their validation fields for per-step validation.
|
|
*/
|
|
export function buildStepsArray(
|
|
config: WizardConfig
|
|
): Array<{ id: string; title: string; fields: string[] }> {
|
|
const STEP_FIELDS_MAP: Record<string, string[]> = {
|
|
welcome: ['competitionCategory'],
|
|
contact: ['contactName', 'contactEmail', 'contactPhone', 'country'],
|
|
project: ['projectName', 'description', 'oceanIssue'],
|
|
team: [],
|
|
additional: [],
|
|
review: ['gdprConsent'],
|
|
}
|
|
|
|
return getActiveSteps(config).map((step) => ({
|
|
id: step.id,
|
|
title: step.title ?? step.id.charAt(0).toUpperCase() + step.id.slice(1),
|
|
fields: (STEP_FIELDS_MAP[step.id] ?? []).filter((f) => isFieldVisible(config, f)),
|
|
}))
|
|
}
|