Add dynamic apply wizard customization with admin settings UI

- 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>
This commit is contained in:
2026-02-08 13:18:20 +01:00
parent 98fe658c33
commit e7c86a7b1b
40 changed files with 4477 additions and 1045 deletions

View File

@@ -9,6 +9,7 @@ import {
} from '../services/in-app-notification'
import { checkRateLimit } from '@/lib/rate-limit'
import { logAudit } from '@/server/utils/audit'
import { parseWizardConfig } from '@/lib/wizard-config'
// Zod schemas for the application form
const teamMemberSchema = z.object({
@@ -19,8 +20,8 @@ const teamMemberSchema = z.object({
})
const applicationSchema = z.object({
// Step 1: Category
competitionCategory: z.nativeEnum(CompetitionCategory),
// Step 1: Category (string to support admin-configured custom values)
competitionCategory: z.string().min(1, 'Competition category is required'),
// Step 2: Contact Info
contactName: z.string().min(2, 'Full name is required'),
@@ -29,11 +30,11 @@ const applicationSchema = z.object({
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
// Step 3: Project Details
// Step 3: Project Details (string to support admin-configured custom values)
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
oceanIssue: z.string().min(1, 'Ocean issue is required'),
// Step 4: Team Members
teamMembers: z.array(teamMemberSchema).optional(),
@@ -50,108 +51,203 @@ const applicationSchema = z.object({
}),
})
// Passthrough version for tRPC input (allows custom fields to pass through)
const applicationInputSchema = applicationSchema.passthrough()
export type ApplicationFormData = z.infer<typeof applicationSchema>
// Known core field names that are stored in dedicated columns (not custom fields)
const CORE_FIELD_NAMES = new Set([
'competitionCategory', 'contactName', 'contactEmail', 'contactPhone',
'country', 'city', 'projectName', 'teamName', 'description', 'oceanIssue',
'teamMembers', 'institution', 'startupCreatedDate', 'wantsMentorship',
'referralSource', 'gdprConsent',
])
/**
* Extract custom field values from form data based on wizard config.
* Returns an object with { customFields: { fieldId: value } } if any custom fields exist.
*/
function extractCustomFieldData(
settingsJson: unknown,
formData: Record<string, unknown>
): Record<string, unknown> {
const config = parseWizardConfig(settingsJson)
if (!config.customFields?.length) return {}
const customFieldData: Record<string, unknown> = {}
for (const field of config.customFields) {
const value = formData[field.id as keyof typeof formData]
if (value !== undefined && value !== '' && value !== null) {
customFieldData[field.id] = value
}
}
if (Object.keys(customFieldData).length === 0) return {}
return { customFields: customFieldData }
}
export const applicationRouter = router({
/**
* Get application configuration for a round
* Get application configuration for a round or edition
*/
getConfig: publicProcedure
.input(z.object({ roundSlug: z.string() }))
.input(
z.object({
slug: z.string(),
mode: z.enum(['edition', 'round']).default('round'),
})
)
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findFirst({
where: { slug: input.roundSlug },
include: {
const now = new Date()
if (input.mode === 'edition') {
// Edition-wide application mode
const program = await ctx.prisma.program.findFirst({
where: { slug: input.slug },
})
if (!program) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Program not found',
})
}
// Check if program supports edition-wide applications
const settingsJson = (program.settingsJson || {}) as Record<string, unknown>
const applyMode = (settingsJson.applyMode as string) || 'round'
if (applyMode !== 'edition') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This program does not support edition-wide applications',
})
}
// Check if applications are open (based on program dates)
const submissionStartDate = settingsJson.submissionStartDate
? new Date(settingsJson.submissionStartDate as string)
: null
const submissionEndDate = settingsJson.submissionEndDate
? new Date(settingsJson.submissionEndDate as string)
: null
let isOpen = false
let gracePeriodEnd: Date | null = null
if (submissionStartDate && submissionEndDate) {
isOpen = now >= submissionStartDate && now <= submissionEndDate
// Check grace period
const lateSubmissionGrace = settingsJson.lateSubmissionGrace as number | undefined
if (!isOpen && lateSubmissionGrace) {
gracePeriodEnd = new Date(submissionEndDate.getTime() + lateSubmissionGrace * 60 * 60 * 1000)
isOpen = now <= gracePeriodEnd
}
} else {
isOpen = program.status === 'ACTIVE'
}
const wizardConfig = parseWizardConfig(program.settingsJson)
return {
mode: 'edition' as const,
program: {
select: {
id: true,
name: true,
year: true,
description: true,
id: program.id,
name: program.name,
year: program.year,
description: program.description,
slug: program.slug,
submissionStartDate,
submissionEndDate,
gracePeriodEnd,
isOpen,
},
wizardConfig,
oceanIssueOptions: wizardConfig.oceanIssues ?? [],
competitionCategories: wizardConfig.competitionCategories ?? [],
}
} else {
// Round-specific application mode (backward compatible)
const round = await ctx.prisma.round.findFirst({
where: { slug: input.slug },
include: {
program: {
select: {
id: true,
name: true,
year: true,
description: true,
settingsJson: true,
},
},
},
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application round not found',
})
}
// Check if submissions are open
const now = new Date()
let isOpen = false
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
} else {
isOpen = round.status === 'ACTIVE'
}
// Calculate grace period if applicable
let gracePeriodEnd: Date | null = null
if (round.lateSubmissionGrace && round.submissionEndDate) {
gracePeriodEnd = new Date(round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000)
if (now <= gracePeriodEnd) {
isOpen = true
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Application round not found',
})
}
}
return {
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionStartDate: round.submissionStartDate,
submissionEndDate: round.submissionEndDate,
submissionDeadline: round.submissionDeadline,
lateSubmissionGrace: round.lateSubmissionGrace,
gracePeriodEnd,
phase1Deadline: round.phase1Deadline,
phase2Deadline: round.phase2Deadline,
isOpen,
},
program: round.program,
oceanIssueOptions: [
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
{ value: 'TECHNOLOGY_INNOVATION', label: 'Technology & innovations' },
{ value: 'SUSTAINABLE_SHIPPING', label: 'Sustainable shipping & yachting' },
{ value: 'BLUE_CARBON', label: 'Blue carbon' },
{ value: 'HABITAT_RESTORATION', label: 'Restoration of marine habitats & ecosystems' },
{ value: 'COMMUNITY_CAPACITY', label: 'Capacity building for coastal communities' },
{ value: 'SUSTAINABLE_FISHING', label: 'Sustainable fishing and aquaculture & blue food' },
{ value: 'CONSUMER_AWARENESS', label: 'Consumer awareness and education' },
{ value: 'OCEAN_ACIDIFICATION', label: 'Mitigation of ocean acidification' },
{ value: 'OTHER', label: 'Other' },
],
competitionCategories: [
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concepts',
description: 'For students and recent graduates with innovative ocean-focused business ideas',
// Check if submissions are open
let isOpen = false
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
} else {
isOpen = round.status === 'ACTIVE'
}
// Calculate grace period if applicable
let gracePeriodEnd: Date | null = null
if (round.lateSubmissionGrace && round.submissionEndDate) {
gracePeriodEnd = new Date(round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000)
if (now <= gracePeriodEnd) {
isOpen = true
}
}
const roundWizardConfig = parseWizardConfig(round.program.settingsJson)
const { settingsJson: _s, ...programData } = round.program
return {
mode: 'round' as const,
round: {
id: round.id,
name: round.name,
slug: round.slug,
submissionStartDate: round.submissionStartDate,
submissionEndDate: round.submissionEndDate,
submissionDeadline: round.submissionDeadline,
lateSubmissionGrace: round.lateSubmissionGrace,
gracePeriodEnd,
phase1Deadline: round.phase1Deadline,
phase2Deadline: round.phase2Deadline,
isOpen,
},
{
value: 'STARTUP',
label: 'Start-ups',
description: 'For established companies working on ocean protection solutions',
},
],
program: programData,
wizardConfig: roundWizardConfig,
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
competitionCategories: roundWizardConfig.competitionCategories ?? [],
}
}
}),
/**
* Submit a new application
* Submit a new application (edition-wide or round-specific)
*/
submit: publicProcedure
.input(
z.object({
roundId: z.string(),
data: applicationSchema,
mode: z.enum(['edition', 'round']).default('round'),
programId: z.string().optional(),
roundId: z.string().optional(),
data: applicationInputSchema,
})
)
.mutation(async ({ ctx, input }) => {
@@ -165,56 +261,143 @@ export const applicationRouter = router({
})
}
const { roundId, data } = input
const { mode, programId, roundId, data } = input
// Verify round exists and is open
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: { program: true },
})
const now = new Date()
// Check submission window
let isOpen = false
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
// Check grace period
if (!isOpen && round.lateSubmissionGrace) {
const gracePeriodEnd = new Date(
round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000
)
isOpen = now <= gracePeriodEnd
}
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
} else {
isOpen = round.status === 'ACTIVE'
}
if (!isOpen) {
// Validate input based on mode
if (mode === 'edition' && !programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Applications are currently closed for this round',
message: 'programId is required for edition-wide applications',
})
}
// Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({
where: {
roundId,
submittedByEmail: data.contactEmail,
},
})
if (existingProject) {
if (mode === 'round' && !roundId) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An application with this email already exists for this round',
code: 'BAD_REQUEST',
message: 'roundId is required for round-specific applications',
})
}
const now = new Date()
let program: { id: string; name: string; year: number; status: string; settingsJson?: unknown }
let isOpen = false
if (mode === 'edition') {
// Edition-wide application
program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: programId },
select: {
id: true,
name: true,
year: true,
status: true,
settingsJson: true,
},
})
// Check if program supports edition-wide applications
const settingsJson = (program.settingsJson || {}) as Record<string, unknown>
const applyMode = (settingsJson.applyMode as string) || 'round'
if (applyMode !== 'edition') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This program does not support edition-wide applications',
})
}
// Check submission window
const submissionStartDate = settingsJson.submissionStartDate
? new Date(settingsJson.submissionStartDate as string)
: null
const submissionEndDate = settingsJson.submissionEndDate
? new Date(settingsJson.submissionEndDate as string)
: null
if (submissionStartDate && submissionEndDate) {
isOpen = now >= submissionStartDate && now <= submissionEndDate
// Check grace period
const lateSubmissionGrace = settingsJson.lateSubmissionGrace as number | undefined
if (!isOpen && lateSubmissionGrace) {
const gracePeriodEnd = new Date(submissionEndDate.getTime() + lateSubmissionGrace * 60 * 60 * 1000)
isOpen = now <= gracePeriodEnd
}
} else {
isOpen = program.status === 'ACTIVE'
}
if (!isOpen) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Applications are currently closed for this edition',
})
}
// Check if email already submitted for this edition
const existingProject = await ctx.prisma.project.findFirst({
where: {
programId,
roundId: null,
submittedByEmail: data.contactEmail,
},
})
if (existingProject) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An application with this email already exists for this edition',
})
}
} else {
// Round-specific application (backward compatible)
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: { program: true },
})
program = round.program
// Check submission window
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
// Check grace period
if (!isOpen && round.lateSubmissionGrace) {
const gracePeriodEnd = new Date(
round.submissionEndDate.getTime() + round.lateSubmissionGrace * 60 * 60 * 1000
)
isOpen = now <= gracePeriodEnd
}
} else if (round.submissionDeadline) {
isOpen = now <= round.submissionDeadline
} else {
isOpen = round.status === 'ACTIVE'
}
if (!isOpen) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Applications are currently closed for this round',
})
}
// Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({
where: {
roundId,
submittedByEmail: data.contactEmail,
},
})
if (existingProject) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An application with this email already exists for this round',
})
}
}
// Check if user exists, or create a new applicant user
let user = await ctx.prisma.user.findUnique({
where: { email: data.contactEmail },
@@ -232,15 +415,26 @@ export const applicationRouter = router({
})
}
// Map string values to Prisma enums (safe for admin-configured custom values)
const validCategories = Object.values(CompetitionCategory) as string[]
const validOceanIssues = Object.values(OceanIssue) as string[]
const categoryEnum = validCategories.includes(data.competitionCategory)
? (data.competitionCategory as CompetitionCategory)
: null
const oceanIssueEnum = validOceanIssues.includes(data.oceanIssue)
? (data.oceanIssue as OceanIssue)
: null
// Create the project
const project = await ctx.prisma.project.create({
data: {
roundId,
programId: program.id,
roundId: mode === 'round' ? roundId! : null,
title: data.projectName,
teamName: data.teamName,
description: data.description,
competitionCategory: data.competitionCategory,
oceanIssue: data.oceanIssue,
competitionCategory: categoryEnum,
oceanIssue: oceanIssueEnum,
country: data.country,
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
institution: data.institution,
@@ -254,6 +448,12 @@ export const applicationRouter = router({
contactPhone: data.contactPhone,
startupCreatedDate: data.startupCreatedDate,
gdprConsentAt: now.toISOString(),
applicationMode: mode,
// Store raw string values for custom categories/issues
...(categoryEnum ? {} : { competitionCategoryRaw: data.competitionCategory }),
...(oceanIssueEnum ? {} : { oceanIssueRaw: data.oceanIssue }),
// Store custom field values from wizard config
...extractCustomFieldData(program.settingsJson, data),
},
},
})
@@ -320,12 +520,12 @@ export const applicationRouter = router({
userId: user.id,
type: NotificationTypes.APPLICATION_SUBMITTED,
title: 'Application Received',
message: `Your application for "${data.projectName}" has been successfully submitted to ${round.program.name}.`,
message: `Your application for "${data.projectName}" has been successfully submitted to ${program.name}.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Application',
metadata: {
projectName: data.projectName,
programName: round.program.name,
programName: program.name,
},
})
@@ -340,24 +540,26 @@ export const applicationRouter = router({
projectName: data.projectName,
applicantName: data.contactName,
applicantEmail: data.contactEmail,
programName: round.program.name,
programName: program.name,
},
})
return {
success: true,
projectId: project.id,
message: `Thank you for applying to ${round.program.name} ${round.program.year}! We will review your application and contact you at ${data.contactEmail}.`,
message: `Thank you for applying to ${program.name} ${program.year}! We will review your application and contact you at ${data.contactEmail}.`,
}
}),
/**
* Check if email is already registered for a round
* Check if email is already registered for a round or edition
*/
checkEmailAvailability: publicProcedure
.input(
z.object({
roundId: z.string(),
mode: z.enum(['edition', 'round']).default('round'),
programId: z.string().optional(),
roundId: z.string().optional(),
email: z.string().email(),
})
)
@@ -372,17 +574,28 @@ export const applicationRouter = router({
})
}
const existing = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
submittedByEmail: input.email,
},
})
let existing
if (input.mode === 'edition') {
existing = await ctx.prisma.project.findFirst({
where: {
programId: input.programId,
roundId: null,
submittedByEmail: input.email,
},
})
} else {
existing = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
submittedByEmail: input.email,
},
})
}
return {
available: !existing,
message: existing
? 'An application with this email already exists for this round'
? `An application with this email already exists for this ${input.mode === 'edition' ? 'edition' : 'round'}`
: null,
}
}),