Add dynamic apply wizard customization with admin settings UI
Build and Push Docker Image / build (push) Failing after 3m29s Details

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

View File

@ -1397,7 +1397,7 @@ Rate limiting is applied to: all tRPC endpoints (100 requests/min), auth POST en
---
## 5. Feature Proposals & Improvements — **N/A** (new features, not fixes — preserved for future reference)
## 5. Feature Proposals & Improvements — **PARTIAL** (10 of 26 features implemented)
*Reviewed by: Feature Proposer Agent*
*Date: 2026-02-05*
@ -1410,7 +1410,7 @@ This section proposes new features and improvements based on a thorough review o
### Quick Wins (Low Effort, High Impact)
#### 5.1.1 Jury Evaluation Reminders & Deadline Countdown
#### 5.1.1 Jury Evaluation Reminders & Deadline Countdown — **DONE**
**What**: Add automated reminder notifications at configurable intervals before voting deadlines (e.g., 72h, 24h, 1h). Show a prominent countdown timer on the jury dashboard and assignment pages when deadline is approaching.
@ -1422,7 +1422,7 @@ This section proposes new features and improvements based on a thorough review o
---
#### 5.1.2 Evaluation Progress Indicator Per Project
#### 5.1.2 Evaluation Progress Indicator Per Project — **DONE**
**What**: On the evaluation form page (`/jury/projects/[id]/evaluate`), show a visual progress indicator (e.g., "3 of 5 criteria scored") so jury members know how far along they are before submitting.
@ -1434,7 +1434,7 @@ This section proposes new features and improvements based on a thorough review o
---
#### 5.1.3 Bulk Status Update for Projects
#### 5.1.3 Bulk Status Update for Projects — **DONE**
**What**: Allow admins to select multiple projects from the project list and change their status in bulk (e.g., mark 20 projects as SEMIFINALIST at once).
@ -1446,7 +1446,7 @@ This section proposes new features and improvements based on a thorough review o
---
#### 5.1.4 Export Filtering Results as CSV
#### 5.1.4 Export Filtering Results as CSV — **DONE**
**What**: Add CSV export capability for filtering results, similar to the existing evaluation and project score exports.
@ -1458,7 +1458,7 @@ This section proposes new features and improvements based on a thorough review o
---
#### 5.1.5 Observer Access to Reports & Analytics
#### 5.1.5 Observer Access to Reports & Analytics — **DONE**
**What**: Allow observers to view the Reports page (analytics charts and exports) in read-only mode.
@ -1470,7 +1470,7 @@ This section proposes new features and improvements based on a thorough review o
---
#### 5.1.6 Conflict of Interest Declaration
#### 5.1.6 Conflict of Interest Declaration — **DONE**
**What**: When a jury member starts an evaluation, prompt them to declare any conflicts of interest with the project. Store the declaration and allow admins to flag/reassign if needed.
@ -1532,7 +1532,7 @@ This section proposes new features and improvements based on a thorough review o
---
#### 5.2.5 Applicant Portal Enhancements
#### 5.2.5 Applicant Portal Enhancements — **DONE**
**What**: Expand the applicant portal (`/my-submission`) with:
- Application status tracking with timeline (submitted -> under review -> semifinalist -> finalist)
@ -1584,7 +1584,7 @@ This section proposes new features and improvements based on a thorough review o
---
#### 5.2.9 AI-Powered Evaluation Summary
#### 5.2.9 AI-Powered Evaluation Summary — **DONE**
**What**: After all evaluations are submitted for a project, use AI to generate a summary of the jurors' collective feedback. Group common themes, highlight strengths and weaknesses mentioned by multiple jurors.
@ -1699,7 +1699,7 @@ Allow admins to configure webhook URLs for external integrations (CRM, Slack, cu
### Improvements to Existing Features
#### 5.4.1 Smart Assignment Algorithm Improvements
#### 5.4.1 Smart Assignment Algorithm Improvements — **DONE**
**Current state**: The smart assignment algorithm in `assignment.ts:getSuggestions` uses expertise matching (35%), load balancing (20%), and under-min-target bonus (15%). The AI assignment uses GPT for more nuanced matching.
@ -1712,7 +1712,7 @@ Allow admins to configure webhook URLs for external integrations (CRM, Slack, cu
---
#### 5.4.2 Evaluation Form Flexibility
#### 5.4.2 Evaluation Form Flexibility — **DONE**
**Current state**: Evaluation criteria are configurable per round with labels, descriptions, scales, and weights. Criteria support numeric scoring (1-10).
@ -1804,33 +1804,33 @@ Allow admins to configure webhook URLs for external integrations (CRM, Slack, cu
### Priority Matrix
| Priority | Feature | Impact | Effort |
|----------|---------|--------|--------|
| P0 | 5.1.1 Jury Evaluation Reminders | High | Low |
| P0 | 5.1.2 Evaluation Progress Indicator | High | Very Low |
| P0 | 5.1.3 Bulk Project Status Update | High | Low |
| P0 | 5.1.6 Conflict of Interest Declaration | High | Low |
| P1 | 5.1.4 Export Filtering Results | Medium | Very Low |
| P1 | 5.1.5 Observer Access to Reports | Medium | Low |
| P1 | 5.2.1 Email Digest Notifications | High | Medium |
| P1 | 5.2.5 Applicant Portal Enhancements | High | Medium |
| P1 | 5.2.9 AI Evaluation Summary | High | Medium |
| P1 | 5.4.2 Evaluation Form Flexibility | High | Medium |
| P2 | 5.2.2 Evaluation Calibration Tool | Medium | Medium |
| P2 | 5.2.4 Project Comparison View | Medium | Medium |
| P2 | 5.2.6 Round Templates | Medium | Medium |
| P2 | 5.2.7 Jury Availability Preferences | Medium | Medium |
| P2 | 5.2.8 Real-Time Live Voting | Medium | Medium |
| P2 | 5.4.1 Smart Assignment Improvements | Medium | Low |
| P2 | 5.4.4 File Management Improvements | Medium | Medium |
| P2 | 5.4.5 Live Voting UX Improvements | Medium | Medium |
| P2 | 5.4.6 Mentor Dashboard Enhancements | Medium | Medium |
| P3 | 5.2.3 Multi-Language Support | High | High |
| P3 | 5.3.1 Public Website Module | High | High |
| P3 | 5.3.2 Communication Hub | High | High |
| P3 | 5.3.3 Advanced Analytics Dashboard | Medium | High |
| P3 | 5.3.4 Applicant Self-Service Drafts | Medium | Medium |
| P3 | 5.3.5 Webhooks & API Integration | Medium | Medium |
| P3 | 5.3.6 Peer Review / Collaborative Notes | Medium | High |
| P3 | 5.4.3 Audit Log Enhancements | Low | Medium |
| P3 | 5.4.7 Application Form Builder | Medium | High |
| Priority | Feature | Impact | Effort | Status |
|----------|---------|--------|--------|--------|
| P0 | 5.1.1 Jury Evaluation Reminders | High | Low | **DONE** |
| P0 | 5.1.2 Evaluation Progress Indicator | High | Very Low | **DONE** |
| P0 | 5.1.3 Bulk Project Status Update | High | Low | **DONE** |
| P0 | 5.1.6 Conflict of Interest Declaration | High | Low | **DONE** |
| P1 | 5.1.4 Export Filtering Results | Medium | Very Low | **DONE** |
| P1 | 5.1.5 Observer Access to Reports | Medium | Low | **DONE** |
| P1 | 5.2.1 Email Digest Notifications | High | Medium | |
| P1 | 5.2.5 Applicant Portal Enhancements | High | Medium | **DONE** |
| P1 | 5.2.9 AI Evaluation Summary | High | Medium | **DONE** |
| P1 | 5.4.2 Evaluation Form Flexibility | High | Medium | **DONE** |
| P2 | 5.2.2 Evaluation Calibration Tool | Medium | Medium | |
| P2 | 5.2.4 Project Comparison View | Medium | Medium | |
| P2 | 5.2.6 Round Templates | Medium | Medium | |
| P2 | 5.2.7 Jury Availability Preferences | Medium | Medium | |
| P2 | 5.2.8 Real-Time Live Voting | Medium | Medium | |
| P2 | 5.4.1 Smart Assignment Improvements | Medium | Low | **DONE** |
| P2 | 5.4.4 File Management Improvements | Medium | Medium | |
| P2 | 5.4.5 Live Voting UX Improvements | Medium | Medium | |
| P2 | 5.4.6 Mentor Dashboard Enhancements | Medium | Medium | |
| P3 | 5.2.3 Multi-Language Support | High | High | |
| P3 | 5.3.1 Public Website Module | High | High | |
| P3 | 5.3.2 Communication Hub | High | High | |
| P3 | 5.3.3 Advanced Analytics Dashboard | Medium | High | |
| P3 | 5.3.4 Applicant Self-Service Drafts | Medium | Medium | |
| P3 | 5.3.5 Webhooks & API Integration | Medium | Medium | |
| P3 | 5.3.6 Peer Review / Collaborative Notes | Medium | High | |
| P3 | 5.4.3 Audit Log Enhancements | Low | Medium | |
| P3 | 5.4.7 Application Form Builder | Medium | High | |

View File

@ -0,0 +1,51 @@
-- Universal Apply Page: Make Project.roundId nullable and add programId FK
-- This migration enables projects to be submitted to a program/edition without being assigned to a specific round
-- Step 1: Add Program.slug for edition-wide apply URLs (nullable for existing programs)
ALTER TABLE "Program" ADD COLUMN "slug" TEXT;
CREATE UNIQUE INDEX "Program_slug_key" ON "Program"("slug");
-- Step 2: Add programId column (nullable initially to handle existing data)
ALTER TABLE "Project" ADD COLUMN "programId" TEXT;
-- Step 3: Backfill programId from existing round relationships
-- Every project currently has a roundId, so we can populate programId from Round.programId
UPDATE "Project" p
SET "programId" = r."programId"
FROM "Round" r
WHERE p."roundId" = r.id;
-- Step 4: Verify backfill succeeded (should be 0 rows with NULL programId)
-- If this fails, manual intervention is needed
DO $$
DECLARE
null_count INTEGER;
BEGIN
SELECT COUNT(*) INTO null_count FROM "Project" WHERE "programId" IS NULL;
IF null_count > 0 THEN
RAISE EXCEPTION 'Migration failed: % projects have NULL programId after backfill', null_count;
END IF;
END $$;
-- Step 5: Make programId required (NOT NULL constraint)
ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL;
-- Step 6: Add foreign key constraint for programId
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey"
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE;
-- Step 7: Make roundId nullable (allow projects without round assignment)
ALTER TABLE "Project" ALTER COLUMN "roundId" DROP NOT NULL;
-- Step 8: Update round FK to SET NULL on delete (instead of CASCADE)
-- Projects should remain in the database if their round is deleted
ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey";
ALTER TABLE "Project" ADD CONSTRAINT "Project_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL;
-- Step 9: Add performance indexes
-- Index for filtering unassigned projects (WHERE roundId IS NULL)
CREATE INDEX "Project_programId_idx" ON "Project"("programId");
CREATE INDEX "Project_programId_roundId_idx" ON "Project"("programId", "roundId");
-- Note: The existing "Project_roundId_idx" remains for queries filtering by round

View File

@ -110,12 +110,6 @@ enum SettingCategory {
SECURITY
DEFAULTS
WHATSAPP
DIGEST
ANALYTICS
AUDIT_CONFIG
INTEGRATIONS
LOCALIZATION
COMMUNICATION
}
enum NotificationChannel {
@ -216,13 +210,6 @@ model User {
notificationPreference NotificationChannel @default(EMAIL)
whatsappOptIn Boolean @default(false)
// Digest preferences (F1)
digestFrequency String @default("none") // none, daily, weekly
// Availability & workload (F2)
availabilityJson Json? @db.JsonB // Array of { start, end } date ranges
preferredWorkload Int? // Preferred number of assignments
// Onboarding (Phase 2B)
onboardingCompletedAt DateTime?
@ -282,23 +269,8 @@ model User {
// Mentor messages
mentorMessages MentorMessage[] @relation("MentorMessageSender")
// Digest logs (F1)
digestLogs DigestLog[]
// Mentor notes & milestones (F8)
mentorNotesMade MentorNote[] @relation("MentorNoteAuthor")
milestoneCompletions MentorMilestoneCompletion[] @relation("MilestoneCompletedByUser")
// Messages (F9)
sentMessages Message[] @relation("MessageSender")
messageRecipients MessageRecipient[]
// Webhooks (F12)
createdWebhooks Webhook[] @relation("WebhookCreatedBy")
// Discussion comments (F13)
discussionComments DiscussionComment[]
closedDiscussions EvaluationDiscussion[] @relation("DiscussionClosedBy")
// Wizard templates
wizardTemplates WizardTemplate[] @relation("WizardTemplateCreatedBy")
// NextAuth relations
accounts Account[]
@ -355,6 +327,7 @@ model VerificationToken {
model Program {
id String @id @default(cuid())
name String // e.g., "Monaco Ocean Protection Challenge"
slug String? @unique // URL-friendly identifier for edition-wide applications
year Int // e.g., 2026
status ProgramStatus @default(DRAFT)
description String?
@ -364,17 +337,35 @@ model Program {
updatedAt DateTime @updatedAt
// Relations
projects Project[]
rounds Round[]
learningResources LearningResource[]
partners Partner[]
specialAwards SpecialAward[]
taggingJobs TaggingJob[]
mentorMilestones MentorMilestone[]
wizardTemplates WizardTemplate[]
@@unique([name, year])
@@index([status])
}
model WizardTemplate {
id String @id @default(cuid())
name String
description String?
config Json @db.JsonB
isGlobal Boolean @default(false)
programId String?
program Program? @relation(fields: [programId], references: [id], onDelete: Cascade)
createdBy String
creator User @relation("WizardTemplateCreatedBy", fields: [createdBy], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([programId])
@@index([isGlobal])
}
model Round {
id String @id @default(cuid())
programId String
@ -461,7 +452,8 @@ model EvaluationForm {
model Project {
id String @id @default(cuid())
roundId String
programId String
roundId String?
status ProjectStatus @default(SUBMITTED)
// Core fields
@ -507,11 +499,6 @@ model Project {
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
logoProvider String? // Storage provider used: 's3' or 'local'
// Draft saving (F11)
isDraft Boolean @default(false)
draftDataJson Json? @db.JsonB
draftExpiresAt DateTime?
// Flexible fields
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
@ -521,7 +508,8 @@ model Project {
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
files ProjectFile[]
assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
@ -535,9 +523,10 @@ model Project {
statusHistory ProjectStatusHistory[]
mentorMessages MentorMessage[]
evaluationSummaries EvaluationSummary[]
discussions EvaluationDiscussion[]
@@index([programId])
@@index([roundId])
@@index([programId, roundId])
@@index([status])
@@index([tags])
@@index([submissionSource])
@ -564,10 +553,6 @@ model ProjectFile {
isLate Boolean @default(false) // Uploaded after round deadline
// File versioning (F7)
version Int @default(1)
replacedById String? // Points to newer version of this file
createdAt DateTime @default(now())
// Relations
@ -717,10 +702,6 @@ model AuditLog {
// Details
detailsJson Json? @db.JsonB // Before/after values, additional context
// Audit enhancements (F14)
sessionId String? // Groups actions in same user session
previousDataJson Json? @db.JsonB // Snapshot of data before change
// Request info
ipAddress String?
userAgent String?
@ -997,12 +978,6 @@ model LiveVotingSession {
votingEndsAt DateTime?
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
// Live voting UX enhancements (F5/F6)
presentationSettingsJson Json? @db.JsonB // theme, auto-advance, branding
allowAudienceVotes Boolean @default(false)
audienceVoteWeight Float @default(0) // 0-1 weight relative to jury
tieBreakerMethod String @default("admin_decides") // admin_decides, highest_individual, revote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -1019,7 +994,6 @@ model LiveVote {
projectId String
userId String
score Int // 1-10
isAudienceVote Boolean @default(false) // F6: audience voting
votedAt DateTime @default(now())
// Relations
@ -1030,7 +1004,6 @@ model LiveVote {
@@index([sessionId])
@@index([projectId])
@@index([userId])
@@index([isAudienceVote])
}
// =============================================================================
@ -1074,15 +1047,9 @@ model MentorAssignment {
expertiseMatchScore Float?
aiReasoning String? @db.Text
// Mentor dashboard enhancements (F8)
lastViewedAt DateTime?
completionStatus String @default("in_progress") // in_progress, completed, paused
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
notes MentorNote[]
milestoneCompletions MentorMilestoneCompletion[]
@@index([mentorId])
@@index([method])
@ -1487,242 +1454,3 @@ model MentorMessage {
@@index([projectId, createdAt])
}
// =============================================================================
// DIGEST LOGS (F1: Email Digest)
// =============================================================================
model DigestLog {
id String @id @default(cuid())
userId String
digestType String // "daily", "weekly"
contentJson Json @db.JsonB
sentAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([sentAt])
}
// =============================================================================
// ROUND TEMPLATES (F3)
// =============================================================================
model RoundTemplate {
id String @id @default(cuid())
name String
description String? @db.Text
programId String? // null = global template
roundType RoundType @default(EVALUATION)
criteriaJson Json @db.JsonB
settingsJson Json? @db.JsonB
assignmentConfig Json? @db.JsonB
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([programId])
}
// =============================================================================
// MENTOR NOTES & MILESTONES (F8)
// =============================================================================
model MentorNote {
id String @id @default(cuid())
mentorAssignmentId String
authorId String
content String @db.Text
isVisibleToAdmin Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
author User @relation("MentorNoteAuthor", fields: [authorId], references: [id])
@@index([mentorAssignmentId])
}
model MentorMilestone {
id String @id @default(cuid())
programId String
name String
description String? @db.Text
isRequired Boolean @default(false)
deadlineOffsetDays Int?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
completions MentorMilestoneCompletion[]
@@index([programId])
@@index([sortOrder])
}
model MentorMilestoneCompletion {
id String @id @default(cuid())
milestoneId String
mentorAssignmentId String
completedAt DateTime @default(now())
completedById String
// Relations
milestone MentorMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
completedBy User @relation("MilestoneCompletedByUser", fields: [completedById], references: [id])
@@unique([milestoneId, mentorAssignmentId])
@@index([mentorAssignmentId])
}
// =============================================================================
// COMMUNICATION HUB (F9)
// =============================================================================
model Message {
id String @id @default(cuid())
senderId String
recipientType String // USER, ROLE, ROUND_JURY, PROGRAM_TEAM, ALL
recipientFilter Json? @db.JsonB
roundId String?
templateId String?
subject String
body String @db.Text
deliveryChannels String[]
scheduledAt DateTime?
sentAt DateTime?
metadata Json? @db.JsonB
createdAt DateTime @default(now())
// Relations
sender User @relation("MessageSender", fields: [senderId], references: [id])
template MessageTemplate? @relation(fields: [templateId], references: [id])
recipients MessageRecipient[]
@@index([senderId])
@@index([sentAt])
@@index([scheduledAt])
}
model MessageTemplate {
id String @id @default(cuid())
name String
category String
subject String
body String @db.Text
variables Json? @db.JsonB
isActive Boolean @default(true)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
messages Message[]
@@index([category])
@@index([isActive])
}
model MessageRecipient {
id String @id @default(cuid())
messageId String
userId String
channel String // EMAIL, IN_APP, WHATSAPP
isRead Boolean @default(false)
readAt DateTime?
deliveredAt DateTime?
// Relations
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([messageId])
@@index([userId, isRead])
}
// =============================================================================
// WEBHOOKS (F12)
// =============================================================================
model Webhook {
id String @id @default(cuid())
name String
url String
secret String // HMAC signing key
events String[]
headers Json? @db.JsonB
isActive Boolean @default(true)
maxRetries Int @default(3)
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
createdBy User @relation("WebhookCreatedBy", fields: [createdById], references: [id])
deliveries WebhookDelivery[]
@@index([isActive])
}
model WebhookDelivery {
id String @id @default(cuid())
webhookId String
event String
payload Json @db.JsonB
responseStatus Int?
responseBody String? @db.Text
attempts Int @default(0)
lastAttemptAt DateTime?
status String @default("PENDING") // PENDING, DELIVERED, FAILED
createdAt DateTime @default(now())
// Relations
webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
@@index([webhookId])
@@index([status])
@@index([createdAt])
}
// =============================================================================
// PEER REVIEW / EVALUATION DISCUSSIONS (F13)
// =============================================================================
model EvaluationDiscussion {
id String @id @default(cuid())
projectId String
roundId String
status String @default("open") // open, closed
createdAt DateTime @default(now())
closedAt DateTime?
closedById String?
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
closedBy User? @relation("DiscussionClosedBy", fields: [closedById], references: [id])
comments DiscussionComment[]
@@unique([projectId, roundId])
@@index([roundId])
@@index([status])
}
model DiscussionComment {
id String @id @default(cuid())
discussionId String
userId String
content String @db.Text
createdAt DateTime @default(now())
// Relations
discussion EvaluationDiscussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([discussionId, createdAt])
}

View File

@ -365,6 +365,7 @@ async function main() {
// Create project
const project = await prisma.project.create({
data: {
programId: round.programId,
roundId: round.id,
title: projectName,
description: row['Comment ']?.trim() || null,

View File

@ -69,7 +69,7 @@ import {
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface Assignment {
projectId: string
@ -99,6 +99,7 @@ interface ParsedUser {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const ROLE_LABELS: Record<Role, string> = {
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
@ -265,6 +266,11 @@ export default function MemberInvitePage() {
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const utils = trpc.useUtils()
// Fetch current user to check role
const { data: currentUser } = trpc.user.me.useQuery()
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const bulkCreate = trpc.user.bulkCreate.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table when navigating back
@ -393,19 +399,22 @@ export default function MemberInvitePage() {
const name = nameKey ? row[nameKey]?.trim() : undefined
const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : ''
const role: Role =
rawRole === 'MENTOR'
rawRole === 'PROGRAM_ADMIN'
? 'PROGRAM_ADMIN'
: rawRole === 'MENTOR'
? 'MENTOR'
: rawRole === 'OBSERVER'
? 'OBSERVER'
: 'JURY_MEMBER'
const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false
const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
return {
email,
name,
role,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !email
? 'No email found'
@ -413,6 +422,8 @@ export default function MemberInvitePage() {
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@ -421,7 +432,7 @@ export default function MemberInvitePage() {
},
})
},
[]
[isSuperAdmin]
)
// --- Parse manual rows into ParsedUser format ---
@ -433,6 +444,7 @@ export default function MemberInvitePage() {
const email = r.email.trim().toLowerCase()
const isValidFormat = emailRegex.test(email)
const isDuplicate = seenEmails.has(email)
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin
if (isValidFormat && !isDuplicate) seenEmails.add(email)
return {
email,
@ -440,12 +452,14 @@ export default function MemberInvitePage() {
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
assignments: r.assignments.length > 0 ? r.assignments : undefined,
isValid: isValidFormat && !isDuplicate,
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
isDuplicate,
error: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: isUnauthorizedAdmin
? 'Only super admins can invite program admins'
: undefined,
}
})
@ -524,6 +538,11 @@ export default function MemberInvitePage() {
<CardTitle>Invite Members</CardTitle>
<CardDescription>
Add members individually or upload a CSV file
{isSuperAdmin && (
<span className="block mt-1 text-primary font-medium">
As a super admin, you can also invite program admins
</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@ -627,6 +646,11 @@ export default function MemberInvitePage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && (
<SelectItem value="PROGRAM_ADMIN">
Program Admin
</SelectItem>
)}
<SelectItem value="JURY_MEMBER">
Jury Member
</SelectItem>

File diff suppressed because it is too large Load Diff

View File

@ -45,18 +45,23 @@ export default function EditProgramPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
status: 'DRAFT',
applyMode: 'round' as 'edition' | 'round' | 'both',
})
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
useEffect(() => {
if (program) {
const settings = (program.settingsJson as Record<string, any>) || {}
setFormData({
name: program.name,
slug: program.slug || '',
description: program.description || '',
status: program.status,
applyMode: settings.applyMode || 'round',
})
}
}, [program])
@ -89,8 +94,12 @@ export default function EditProgramPage() {
updateProgram.mutate({
id,
name: formData.name,
slug: formData.slug || undefined,
description: formData.description || undefined,
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
settingsJson: {
applyMode: formData.applyMode,
},
})
}
@ -196,6 +205,41 @@ export default function EditProgramPage() {
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="slug">Edition Slug</Label>
<Input
id="slug"
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
placeholder="e.g., mopc-2026"
/>
<p className="text-xs text-muted-foreground">
URL-friendly identifier for edition-wide applications (optional)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="applyMode">Application Flow</Label>
<Select
value={formData.applyMode}
onValueChange={(value) => setFormData({ ...formData, applyMode: value as 'edition' | 'round' | 'both' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="edition">Edition-wide only</SelectItem>
<SelectItem value="round">Round-specific only</SelectItem>
<SelectItem value="both">Allow both</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Controls whether applicants apply to the program or specific rounds
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea

View File

@ -33,6 +33,7 @@ import {
FolderKanban,
Eye,
Pencil,
Wand2,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
@ -146,6 +147,12 @@ async function ProgramsContent() {
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Wand2 className="mr-2 h-4 w-4" />
Apply Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@ -194,6 +201,12 @@ async function ProgramsContent() {
Edit
</Link>
</Button>
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}/apply-settings`}>
<Wand2 className="mr-2 h-4 w-4" />
Apply
</Link>
</Button>
</div>
</CardContent>
</Card>

View File

@ -78,9 +78,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
id: projectId,
})
// Fetch files
// Fetch files (flat list for backward compatibility)
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch grouped files by round (if project has a roundId)
const { data: groupedFiles } = trpc.file.listByProjectForRound.useQuery(
{ projectId, roundId: project?.roundId || '' },
{ enabled: !!project?.roundId }
)
// Fetch available rounds for upload selector (if project has a programId)
const { data: rounds } = trpc.round.listByProgram.useQuery(
{ programId: project?.programId || '' },
{ enabled: !!project?.programId }
)
// Fetch assignments
const { data: assignments } = trpc.assignment.listByProject.useQuery({
projectId,
@ -492,7 +504,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
{groupedFiles && groupedFiles.length > 0 ? (
<FileViewer groupedFiles={groupedFiles} />
) : files && files.length > 0 ? (
<FileViewer
projectId={projectId}
files={files.map((f) => ({
@ -516,8 +530,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
roundId={project.roundId || undefined}
availableRounds={rounds?.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name }))}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
if (project.roundId) {
utils.file.listByProjectForRound.invalidate({ projectId, roundId: project.roundId })
}
}}
/>
</div>

View File

@ -0,0 +1,345 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [targetRoundId, setTargetRoundId] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
)
const { data: rounds, isLoading: isLoadingRounds } = trpc.round.listByProgram.useQuery(
{ programId: selectedProgramId },
{ enabled: !!selectedProgramId }
)
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetRoundId('')
refetch()
},
onError: (error) => {
toast.error(error.message || 'Failed to assign projects')
},
})
const handleBulkAssign = () => {
if (selectedProjects.length === 0 || !targetRoundId) return
assignMutation.mutate({
projectIds: selectedProjects,
roundId: targetRoundId,
})
}
const handleQuickAssign = (projectId: string, roundId: string) => {
assignMutation.mutate({
projectIds: [projectId],
roundId,
})
}
const toggleSelectAll = () => {
if (!poolData?.projects) return
if (selectedProjects.length === poolData.projects.length) {
setSelectedProjects([])
} else {
setSelectedProjects(poolData.projects.map((p) => p.id))
}
}
const toggleSelectProject = (projectId: string) => {
if (selectedProjects.includes(projectId)) {
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
} else {
setSelectedProjects([...selectedProjects, projectId])
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to evaluation rounds
</p>
</div>
{/* Program Selector */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: any) => {
setCategoryFilter(value)
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Search</label>
<Input
placeholder="Project or team name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
/>
</div>
{selectedProjects.length > 0 && (
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
</Button>
)}
</div>
</Card>
{/* Projects Table */}
{selectedProgramId && (
<>
{isLoadingPool ? (
<Card className="p-4">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</Card>
) : poolData && poolData.total > 0 ? (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<Checkbox
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody>
{poolData.projects.map((project) => (
<tr key={project.id} className="border-b hover:bg-muted/50">
<td className="p-3">
<Checkbox
checked={selectedProjects.includes(project.id)}
onCheckedChange={() => toggleSelectProject(project.id)}
/>
</td>
<td className="p-3">
<Link
href={`/admin/projects/${project.id}`}
className="hover:underline"
>
<div className="font-medium">{project.title}</div>
<div className="text-sm text-muted-foreground">{project.teamName}</div>
</Link>
</td>
<td className="p-3">
<Badge variant="outline">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country || '-'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.submittedAt
? new Date(project.submittedAt).toLocaleDateString()
: '-'}
</td>
<td className="p-3">
{isLoadingRounds ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Pagination */}
{poolData.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === poolData.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
</Card>
)}
</>
)}
{!selectedProgramId && (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
</Card>
)}
{/* Bulk Assignment Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Projects to Round</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetRoundId || assignMutation.isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -9,6 +9,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
import { isFuture, isPast } from 'date-fns'
@ -76,6 +77,9 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
where: { id: projectId },
include: {
files: true,
_count: {
select: { files: true },
},
},
})
@ -266,6 +270,13 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
</Card>
)}
{/* Project Files */}
<CollapsibleFilesSection
projectId={project.id}
roundId={round.id}
fileCount={project._count?.files || 0}
/>
{/* Evaluation Form with COI Gate */}
<EvaluationFormWithCOI
assignmentId={assignment.id}

View File

@ -16,7 +16,8 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { FileViewer, FileViewerSkeleton } from '@/components/shared/file-viewer'
import { FileViewerSkeleton } from '@/components/shared/file-viewer'
import { ProjectFilesSection } from '@/components/jury/project-files-section'
import {
ArrowLeft,
ArrowRight,
@ -255,7 +256,9 @@ async function ProjectContent({ projectId }: { projectId: string }) {
</Card>
{/* Files */}
<FileViewer files={project.files} />
<Suspense fallback={<FileViewerSkeleton />}>
<ProjectFilesSection projectId={project.id} roundId={assignment.roundId} />
</Suspense>
</div>
{/* Sidebar */}

View File

@ -1,423 +1,65 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from '@/components/forms/apply-steps'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { cn } from '@/lib/utils'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
// Form validation schema
const teamMemberSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
const applicationSchema = z.object({
competitionCategory: z.nativeEnum(CompetitionCategory),
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
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),
teamMembers: z.array(teamMemberSchema).optional(),
institution: z.string().optional(),
startupCreatedDate: z.string().optional(),
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const STEPS = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: [] },
{ id: 'additional', title: 'Details', fields: [] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
export default function ApplyWizardPage() {
export default function RoundApplyPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ roundSlug: slug },
{ slug, mode: 'round' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.message)
},
onError: (error) => toast.error(error.message),
})
const form = useForm<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
defaultValues: {
competitionCategory: undefined,
contactName: '',
contactEmail: '',
contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
},
mode: 'onChange',
})
const { watch, trigger, handleSubmit } = form
const competitionCategory = watch('competitionCategory')
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
const isStartup = competitionCategory === 'STARTUP'
const validateCurrentStep = async () => {
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
if (currentFields.length === 0) return true
return await trigger(currentFields)
}
const nextStep = async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep < STEPS.length - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}
const onSubmit = async (data: ApplicationFormData) => {
if (!config) return
await submitMutation.mutateAsync({
roundId: config.round.id,
data,
})
}
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep])
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-center gap-3">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-lg text-muted-foreground">Loading application...</span>
</div>
</div>
</div>
)
}
// Error state
if (error) {
if (error || !config || config.mode !== 'round') {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error.message}</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
</div>
</div>
)
}
// Applications closed state
if (config && !config.round.isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {config.program.name} {config.program.year} has ended.
{config.round.submissionEndDate && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Success state
if (submitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-md text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
<Button onClick={() => router.push('/')}>
Return Home
</Button>
</motion.div>
</div>
)
}
if (!config) return null
const progress = ((currentStep + 1) / STEPS.length) * 100
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 50 : -50,
opacity: 0,
}),
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">{config.program.name}</h1>
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {STEPS.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators */}
<div className="mt-3 flex justify-between">
{STEPS.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'hidden text-xs font-medium transition-colors sm:block',
index === currentStep && 'text-primary',
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-4xl px-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{currentStep === 0 && (
<StepWelcome
<ApplyWizardDynamic
mode="round"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
value={competitionCategory}
onChange={(value) => form.setValue('competitionCategory', value)}
roundId={config.round.id}
isOpen={config.round.isOpen}
submissionDeadline={config.round.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'round',
roundId: config.round.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)}
{currentStep === 1 && <StepContact form={form} />}
{currentStep === 2 && <StepProject form={form} />}
{currentStep === 3 && <StepTeam form={form} />}
{currentStep === 4 && (
<StepAdditional
form={form}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
/>
)}
{currentStep === 5 && (
<StepReview form={form} programName={config.program.name} />
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || submitMutation.isPending}
className={cn(currentStep === 0 && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form>
</main>
{/* Footer with deadline info */}
{config.round.submissionEndDate && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div>
)
}

View File

@ -0,0 +1,65 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function EditionApplyPage() {
const params = useParams()
const router = useRouter()
const programSlug = params.programSlug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug: programSlug, mode: 'edition' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onError: (error) => toast.error(error.message),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !config || config.mode !== 'edition') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
</div>
</div>
)
}
return (
<ApplyWizardDynamic
mode="edition"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
programId={config.program.id}
isOpen={config.program.isOpen}
submissionDeadline={config.program.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'edition',
programId: config.program.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)
}

View File

@ -8,14 +8,16 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import type { ApplicationFormData } from '@/server/routers/application'
import type { WizardConfig } from '@/types/wizard-config'
interface StepAdditionalProps {
form: UseFormReturn<ApplicationFormData>
isBusinessConcept: boolean
isStartup: boolean
config?: WizardConfig
}
export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAdditionalProps) {
export function StepAdditional({ form, isBusinessConcept, isStartup, config }: StepAdditionalProps) {
const { register, formState: { errors }, setValue, watch } = form
const wantsMentorship = watch('wantsMentorship')
@ -86,6 +88,7 @@ export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAddit
)}
{/* Mentorship */}
{config?.features?.enableMentorship !== false && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
@ -114,6 +117,7 @@ export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAddit
</div>
</div>
</motion.div>
)}
{/* Referral Source */}
<motion.div

View File

@ -8,16 +8,27 @@ import { Label } from '@/components/ui/label'
import { PhoneInput } from '@/components/ui/phone-input'
import { CountrySelect } from '@/components/ui/country-select'
import type { ApplicationFormData } from '@/server/routers/application'
import type { WizardConfig } from '@/types/wizard-config'
import { isFieldVisible, isFieldRequired, getFieldConfig } from '@/lib/wizard-config'
interface StepContactProps {
form: UseFormReturn<ApplicationFormData>
config?: WizardConfig
}
export function StepContact({ form }: StepContactProps) {
export function StepContact({ form, config }: StepContactProps) {
const { register, formState: { errors }, setValue, watch } = form
const country = watch('country')
const phone = watch('contactPhone')
const showPhone = !config || isFieldVisible(config, 'contactPhone')
const showCountry = !config || isFieldVisible(config, 'country')
const showCity = !config || isFieldVisible(config, 'city')
const phoneRequired = !config || isFieldRequired(config, 'contactPhone')
const countryRequired = !config || isFieldRequired(config, 'country')
const phoneLabel = config ? getFieldConfig(config, 'contactPhone').label : undefined
const countryLabel = config ? getFieldConfig(config, 'country').label : undefined
return (
<WizardStepContent
title="Tell us about yourself"
@ -62,9 +73,11 @@ export function StepContact({ form }: StepContactProps) {
</div>
{/* Phone */}
{showPhone && (
<div className="space-y-2">
<Label htmlFor="contactPhone">
Phone Number <span className="text-destructive">*</span>
{phoneLabel ?? 'Phone Number'}{' '}
{phoneRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
</Label>
<PhoneInput
value={phone}
@ -76,11 +89,14 @@ export function StepContact({ form }: StepContactProps) {
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
)}
</div>
)}
{/* Country */}
{showCountry && (
<div className="space-y-2">
<Label>
Country <span className="text-destructive">*</span>
{countryLabel ?? 'Country'}{' '}
{countryRequired ? <span className="text-destructive">*</span> : <span className="text-muted-foreground text-xs">(optional)</span>}
</Label>
<CountrySelect
value={country}
@ -92,8 +108,10 @@ export function StepContact({ form }: StepContactProps) {
<p className="text-sm text-destructive">{errors.country.message}</p>
)}
</div>
)}
{/* City (optional) */}
{showCity && (
<div className="space-y-2">
<Label htmlFor="city">
City <span className="text-muted-foreground text-xs">(optional)</span>
@ -105,6 +123,7 @@ export function StepContact({ form }: StepContactProps) {
className="h-12 text-base"
/>
</div>
)}
</motion.div>
</WizardStepContent>
)

View File

@ -14,35 +14,22 @@ import {
SelectValue,
} from '@/components/ui/select'
import type { ApplicationFormData } from '@/server/routers/application'
import { OceanIssue } from '@prisma/client'
interface OceanIssueOption {
value: OceanIssue
label: string
}
const oceanIssueOptions: OceanIssueOption[] = [
{ 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' },
]
import { type DropdownOption, type WizardConfig, DEFAULT_OCEAN_ISSUES } from '@/types/wizard-config'
import { isFieldVisible, getFieldConfig } from '@/lib/wizard-config'
interface StepProjectProps {
form: UseFormReturn<ApplicationFormData>
oceanIssues?: DropdownOption[]
config?: WizardConfig
}
export function StepProject({ form }: StepProjectProps) {
export function StepProject({ form, oceanIssues, config }: StepProjectProps) {
const issueOptions = oceanIssues ?? DEFAULT_OCEAN_ISSUES
const { register, formState: { errors }, setValue, watch } = form
const oceanIssue = watch('oceanIssue')
const description = watch('description') || ''
const showTeamName = !config || isFieldVisible(config, 'teamName')
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
return (
<WizardStepContent
@ -71,6 +58,7 @@ export function StepProject({ form }: StepProjectProps) {
</div>
{/* Team Name (optional) */}
{showTeamName && (
<div className="space-y-2">
<Label htmlFor="teamName">
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
@ -82,6 +70,7 @@ export function StepProject({ form }: StepProjectProps) {
className="h-12 text-base"
/>
</div>
)}
{/* Ocean Issue */}
<div className="space-y-2">
@ -90,13 +79,13 @@ export function StepProject({ form }: StepProjectProps) {
</Label>
<Select
value={oceanIssue}
onValueChange={(value) => setValue('oceanIssue', value as OceanIssue)}
onValueChange={(value) => setValue('oceanIssue', value)}
>
<SelectTrigger className="h-12 text-base">
<SelectValue placeholder="Select an ocean issue" />
</SelectTrigger>
<SelectContent>
{oceanIssueOptions.map((option) => (
{issueOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View File

@ -22,37 +22,30 @@ import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import type { ApplicationFormData } from '@/server/routers/application'
import { countries } from '@/components/ui/country-select'
const oceanIssueLabels: Record<string, string> = {
POLLUTION_REDUCTION: 'Reduction of pollution',
CLIMATE_MITIGATION: 'Climate change mitigation',
TECHNOLOGY_INNOVATION: 'Technology & innovations',
SUSTAINABLE_SHIPPING: 'Sustainable shipping & yachting',
BLUE_CARBON: 'Blue carbon',
HABITAT_RESTORATION: 'Marine habitat restoration',
COMMUNITY_CAPACITY: 'Coastal community capacity',
SUSTAINABLE_FISHING: 'Sustainable fishing & aquaculture',
CONSUMER_AWARENESS: 'Consumer awareness & education',
OCEAN_ACIDIFICATION: 'Ocean acidification mitigation',
OTHER: 'Other',
}
const categoryLabels: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Start-ups',
}
import { type WizardConfig, DEFAULT_OCEAN_ISSUES, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
interface StepReviewProps {
form: UseFormReturn<ApplicationFormData>
programName: string
config?: WizardConfig
}
export function StepReview({ form, programName }: StepReviewProps) {
export function StepReview({ form, programName, config }: StepReviewProps) {
const { formState: { errors }, setValue, watch } = form
const data = watch()
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
const getOceanIssueLabel = (value: string): string => {
const issues = config?.oceanIssues ?? DEFAULT_OCEAN_ISSUES
return issues.find((i) => i.value === value)?.label ?? value
}
const getCategoryLabel = (value: string): string => {
const cats = config?.competitionCategories ?? DEFAULT_COMPETITION_CATEGORIES
return cats.find((c) => c.value === value)?.label ?? value
}
return (
<WizardStepContent
title="Review your application"
@ -108,12 +101,12 @@ export function StepReview({ form, programName }: StepReviewProps) {
)}
<div className="flex items-center gap-2">
<Badge variant="secondary">
{categoryLabels[data.competitionCategory]}
{getCategoryLabel(data.competitionCategory)}
</Badge>
</div>
<div className="flex items-center gap-2">
<Waves className="h-4 w-4 text-muted-foreground" />
<span>{oceanIssueLabels[data.oceanIssue]}</span>
<span>{getOceanIssueLabel(data.oceanIssue)}</span>
</div>
<div>
<span className="text-muted-foreground">Description:</span>

View File

@ -17,6 +17,7 @@ import {
} from '@/components/ui/select'
import type { ApplicationFormData } from '@/server/routers/application'
import { TeamMemberRole } from '@prisma/client'
import type { WizardConfig } from '@/types/wizard-config'
const roleOptions: { value: TeamMemberRole; label: string }[] = [
{ value: 'MEMBER', label: 'Team Member' },
@ -25,6 +26,7 @@ const roleOptions: { value: TeamMemberRole; label: string }[] = [
interface StepTeamProps {
form: UseFormReturn<ApplicationFormData>
config?: WizardConfig
}
export function StepTeam({ form }: StepTeamProps) {

View File

@ -1,41 +1,28 @@
'use client'
import { motion } from 'motion/react'
import { Waves, Rocket, GraduationCap } from 'lucide-react'
import { Waves, Rocket, GraduationCap, type LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { WizardStepContent } from '@/components/forms/form-wizard'
import { CompetitionCategory } from '@prisma/client'
import { type DropdownOption, type WelcomeMessage, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
interface CategoryOption {
value: CompetitionCategory
label: string
description: string
icon: typeof Rocket
const ICON_MAP: Record<string, LucideIcon> = {
GraduationCap,
Rocket,
}
const categories: CategoryOption[] = [
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concepts',
description: 'For students and recent graduates with innovative ocean-focused business ideas',
icon: GraduationCap,
},
{
value: 'STARTUP',
label: 'Start-ups',
description: 'For established companies working on ocean protection solutions',
icon: Rocket,
},
]
interface StepWelcomeProps {
programName: string
programYear: number
value: CompetitionCategory | null
onChange: (value: CompetitionCategory) => void
value: string | null
onChange: (value: string) => void
categories?: DropdownOption[]
welcomeMessage?: WelcomeMessage
}
export function StepWelcome({ programName, programYear, value, onChange }: StepWelcomeProps) {
export function StepWelcome({ programName, programYear, value, onChange, categories, welcomeMessage }: StepWelcomeProps) {
const categoryOptions = categories ?? DEFAULT_COMPETITION_CATEGORIES
return (
<WizardStepContent>
<div className="flex flex-col items-center text-center">
@ -58,13 +45,13 @@ export function StepWelcome({ programName, programYear, value, onChange }: StepW
transition={{ delay: 0.2 }}
>
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
{programName}
{welcomeMessage?.title ?? programName}
</h1>
<p className="mt-2 text-xl text-primary font-semibold">
{programYear} Application
</p>
<p className="mt-4 max-w-md text-muted-foreground">
Join us in protecting our oceans. Select your category to begin.
{welcomeMessage?.description ?? 'Join us in protecting our oceans. Select your category to begin.'}
</p>
</motion.div>
@ -75,8 +62,8 @@ export function StepWelcome({ programName, programYear, value, onChange }: StepW
transition={{ delay: 0.4 }}
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
>
{categories.map((category) => {
const Icon = category.icon
{categoryOptions.map((category) => {
const Icon = (category.icon ? ICON_MAP[category.icon] : undefined) ?? Waves
const isSelected = value === category.value
return (

View File

@ -0,0 +1,806 @@
'use client'
import { useState, useMemo, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, UseFormReturn } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from './apply-steps'
import type { WizardConfig, WizardStepId, CustomField } from '@/types/wizard-config'
import {
getVisibleSteps,
isFieldVisible,
isFieldRequired,
buildStepsArray,
getCustomFieldsForStep,
} from '@/lib/wizard-config'
import { TeamMemberRole } from '@prisma/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ApplyWizardDynamicProps {
mode: 'edition' | 'round'
config: WizardConfig
programName: string
programYear: number
programId?: string
roundId?: string
isOpen: boolean
submissionDeadline?: Date | string | null
onSubmit: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
// ---------------------------------------------------------------------------
// Animation variants
// ---------------------------------------------------------------------------
const variants = {
enter: (dir: number) => ({ x: dir > 0 ? 50 : -50, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir < 0 ? 50 : -50, opacity: 0 }),
}
// ---------------------------------------------------------------------------
// Custom field renderer
// ---------------------------------------------------------------------------
function CustomFieldRenderer({
field,
form,
}: {
field: CustomField
form: UseFormReturn<Record<string, unknown>>
}) {
const {
register,
formState: { errors },
setValue,
watch,
} = form
const value = watch(field.id)
const error = errors[field.id]
const labelEl = (
<Label htmlFor={field.id}>
{field.label}
{field.required ? (
<span className="text-destructive"> *</span>
) : (
<span className="text-muted-foreground text-xs ml-1">(optional)</span>
)}
</Label>
)
const helpEl = field.helpText ? (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
) : null
const errorEl = error ? (
<p className="text-sm text-destructive">{error.message as string}</p>
) : null
switch (field.type) {
case 'text':
return (
<div className="space-y-2">
{labelEl}
{helpEl}
<Input
id={field.id}
placeholder={field.placeholder}
{...register(field.id)}
className="h-12 text-base"
/>
{errorEl}
</div>
)
case 'textarea':
return (
<div className="space-y-2">
{labelEl}
{helpEl}
<Textarea
id={field.id}
placeholder={field.placeholder}
rows={4}
{...register(field.id)}
className="text-base resize-none"
/>
{errorEl}
</div>
)
case 'number':
return (
<div className="space-y-2">
{labelEl}
{helpEl}
<Input
id={field.id}
type="number"
placeholder={field.placeholder}
{...register(field.id)}
className="h-12 text-base"
/>
{errorEl}
</div>
)
case 'select':
return (
<div className="space-y-2">
{labelEl}
{helpEl}
<Select
value={(value as string) ?? ''}
onValueChange={(v) => setValue(field.id, v)}
>
<SelectTrigger className="h-12 text-base">
<SelectValue placeholder={field.placeholder ?? 'Select...'} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
{errorEl}
</div>
)
case 'multiselect': {
const selected: string[] = Array.isArray(value) ? (value as string[]) : []
return (
<div className="space-y-2">
{labelEl}
{helpEl}
<div className="space-y-2 rounded-lg border p-3">
{(field.options ?? []).map((opt) => {
const checked = selected.includes(opt)
return (
<div key={opt} className="flex items-center gap-2">
<Checkbox
id={`${field.id}-${opt}`}
checked={checked}
onCheckedChange={(c) => {
if (c) {
setValue(field.id, [...selected, opt])
} else {
setValue(
field.id,
selected.filter((s) => s !== opt)
)
}
}}
/>
<Label htmlFor={`${field.id}-${opt}`} className="text-sm font-normal">
{opt}
</Label>
</div>
)
})}
</div>
{errorEl}
</div>
)
}
case 'checkbox':
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id={field.id}
checked={value === true}
onCheckedChange={(c) => setValue(field.id, c === true)}
/>
<Label htmlFor={field.id} className="text-sm font-normal">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
</div>
{helpEl}
{errorEl}
</div>
)
case 'date':
return (
<div className="space-y-2">
{labelEl}
{helpEl}
<Input
id={field.id}
type="date"
{...register(field.id)}
className="h-12 text-base"
/>
{errorEl}
</div>
)
default:
return null
}
}
// ---------------------------------------------------------------------------
// Dynamic schema builder
// ---------------------------------------------------------------------------
function buildDynamicSchema(config: WizardConfig): z.ZodObject<Record<string, z.ZodTypeAny>> {
const shape: Record<string, z.ZodTypeAny> = {}
// Always required
shape.competitionCategory = z.string().min(1, 'Competition category is required')
shape.gdprConsent = z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
})
// Contact fields
if (isFieldVisible(config, 'contactName')) {
shape.contactName = isFieldRequired(config, 'contactName')
? z.string().min(2, 'Full name is required')
: z.string().optional()
} else {
shape.contactName = z.string().optional()
}
if (isFieldVisible(config, 'contactEmail')) {
shape.contactEmail = isFieldRequired(config, 'contactEmail')
? z.string().email('Invalid email address')
: z.string().optional()
} else {
shape.contactEmail = z.string().optional()
}
if (isFieldVisible(config, 'contactPhone')) {
shape.contactPhone = isFieldRequired(config, 'contactPhone')
? z.string().min(5, 'Phone number is required')
: z.string().optional()
} else {
shape.contactPhone = z.string().optional()
}
if (isFieldVisible(config, 'country')) {
shape.country = isFieldRequired(config, 'country')
? z.string().min(2, 'Country is required')
: z.string().optional()
} else {
shape.country = z.string().optional()
}
shape.city = z.string().optional()
// Project fields
if (isFieldVisible(config, 'projectName')) {
shape.projectName = isFieldRequired(config, 'projectName')
? z.string().min(2, 'Project name is required').max(200)
: z.string().optional()
} else {
shape.projectName = z.string().optional()
}
shape.teamName = z.string().optional()
if (isFieldVisible(config, 'description')) {
shape.description = isFieldRequired(config, 'description')
? z.string().min(20, 'Description must be at least 20 characters')
: z.string().optional()
} else {
shape.description = z.string().optional()
}
if (isFieldVisible(config, 'oceanIssue')) {
shape.oceanIssue = isFieldRequired(config, 'oceanIssue')
? z.string().min(1, 'Ocean issue is required')
: z.string().optional()
} else {
shape.oceanIssue = z.string().optional()
}
// Team members
if (config.features?.enableTeamMembers !== false) {
shape.teamMembers = z
.array(
z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
)
.optional()
}
// Additional fields - always optional at schema level
shape.institution = z.string().optional()
shape.startupCreatedDate = z.string().optional()
shape.wantsMentorship = z.boolean().default(false)
shape.referralSource = z.string().optional()
// Custom fields
for (const cf of config.customFields ?? []) {
if (cf.required) {
if (cf.type === 'checkbox') {
shape[cf.id] = z.boolean().refine((v) => v === true, { message: `${cf.label} is required` })
} else if (cf.type === 'multiselect') {
shape[cf.id] = z.array(z.string()).min(1, `${cf.label} is required`)
} else {
shape[cf.id] = z.string().min(1, `${cf.label} is required`)
}
} else {
if (cf.type === 'checkbox') {
shape[cf.id] = z.boolean().optional()
} else if (cf.type === 'multiselect') {
shape[cf.id] = z.array(z.string()).optional()
} else {
shape[cf.id] = z.string().optional()
}
}
}
return z.object(shape)
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ApplyWizardDynamic({
mode,
config,
programName,
programYear,
programId,
roundId,
isOpen,
submissionDeadline,
onSubmit,
isSubmitting,
}: ApplyWizardDynamicProps) {
const router = useRouter()
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
// Build dynamic schema from config
const schema = useMemo(() => buildDynamicSchema(config), [config])
// Build default values
const defaultValues = useMemo(() => {
const defaults: Record<string, unknown> = {
competitionCategory: undefined,
contactName: '',
contactEmail: '',
contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
}
// Add defaults for custom fields
for (const cf of config.customFields ?? []) {
if (cf.type === 'checkbox') {
defaults[cf.id] = false
} else if (cf.type === 'multiselect') {
defaults[cf.id] = []
} else {
defaults[cf.id] = ''
}
}
return defaults
}, [config])
const form = useForm<Record<string, unknown>>({
resolver: zodResolver(schema),
defaultValues,
mode: 'onChange',
})
const { watch, trigger, handleSubmit } = form
const formValues = watch()
const competitionCategory = formValues.competitionCategory as string | undefined
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
const isStartup = competitionCategory === 'STARTUP'
// Visible steps from config
const visibleSteps = useMemo(
() => getVisibleSteps(config, formValues as Record<string, unknown>),
[config, formValues]
)
// Steps array for validation mapping
const stepsArray = useMemo(() => buildStepsArray(config), [config])
// Filtered steps array matching visible step IDs
const activeSteps = useMemo(() => {
const visibleIds = new Set(visibleSteps.map((s) => s.id as string))
return stepsArray.filter((s) => visibleIds.has(s.id))
}, [stepsArray, visibleSteps])
// Validate current step fields
const validateCurrentStep = useCallback(async () => {
if (currentStep >= activeSteps.length) return true
const stepDef = activeSteps[currentStep]
const fields = stepDef.fields as string[]
// Also validate custom fields for this step
const customFields = getCustomFieldsForStep(config, stepDef.id as WizardStepId)
const customFieldIds = customFields.map((cf) => cf.id)
const allFields = [...fields, ...customFieldIds]
if (allFields.length === 0) return true
return await trigger(allFields)
}, [currentStep, activeSteps, config, trigger])
// Navigation
const nextStep = useCallback(async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep < activeSteps.length - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [validateCurrentStep, currentStep, activeSteps.length])
const prevStep = useCallback(() => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [currentStep])
// Handle form submit
const handleFormSubmit = useCallback(
async (data: Record<string, unknown>) => {
try {
await onSubmit(data)
setSubmitted(true)
setSubmissionMessage(
'Thank you for your application! You will receive a confirmation email shortly.'
)
} catch {
// Error handled by parent via onSubmit rejection
}
},
[onSubmit]
)
// Keyboard navigation (skip when focused on textarea or contenteditable)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
const isTextarea = target.tagName === 'TEXTAREA'
const isContentEditable = target.isContentEditable
if (
e.key === 'Enter' &&
!e.shiftKey &&
!isTextarea &&
!isContentEditable &&
currentStep < activeSteps.length - 1
) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep, activeSteps.length, nextStep])
// --- Closed state ---
if (!isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {programName} {programYear} has ended.
{submissionDeadline && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(submissionDeadline).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// --- Success state ---
if (submitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-md text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
<Button onClick={() => router.push('/')}>Return Home</Button>
</motion.div>
</div>
)
}
// --- Wizard ---
const currentStepDef = activeSteps[currentStep]
if (!currentStepDef) return null
const progress = ((currentStep + 1) / activeSteps.length) * 100
const currentStepId = currentStepDef.id as WizardStepId
const customFields = getCustomFieldsForStep(config, currentStepId)
// Render the appropriate step component
function renderStep() {
switch (currentStepId) {
case 'welcome':
return (
<StepWelcome
programName={programName}
programYear={programYear}
value={competitionCategory as string | null}
onChange={(value) => form.setValue('competitionCategory', value)}
categories={config.competitionCategories}
welcomeMessage={config.welcomeMessage}
/>
)
case 'contact':
return <StepContact form={form as UseFormReturn<any>} config={config} />
case 'project':
return (
<StepProject
form={form as UseFormReturn<any>}
oceanIssues={config.oceanIssues}
config={config}
/>
)
case 'team':
if (config.features?.enableTeamMembers === false) return null
return <StepTeam form={form as UseFormReturn<any>} config={config} />
case 'additional':
return (
<StepAdditional
form={form as UseFormReturn<any>}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
config={config}
/>
)
case 'review':
return (
<StepReview
form={form as UseFormReturn<any>}
programName={`${programName} ${programYear}`}
config={config}
/>
)
default:
return null
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5">
{/* Sticky header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">
{programName} {programYear}
</h1>
<p className="text-xs text-muted-foreground">Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {activeSteps.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1 sm:h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators (hidden on mobile) */}
<div className="mt-3 hidden sm:flex justify-between">
{activeSteps.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'text-xs font-medium transition-colors',
index === currentStep && 'text-primary',
index < currentStep &&
'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-3xl px-4 py-8">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="relative min-h-[400px] sm:min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{renderStep()}
{/* Custom fields for this step */}
{customFields.length > 0 && (
<div className="mt-6 space-y-4 mx-auto max-w-md">
{customFields.map((field) => (
<CustomFieldRenderer key={field.id} field={field} form={form} />
))}
</div>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || isSubmitting}
className={cn(
'h-11 sm:h-10',
currentStep === 0 && 'invisible'
)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < activeSteps.length - 1 ? (
<Button
type="button"
onClick={nextStep}
className="h-11 sm:h-10"
>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button
type="submit"
disabled={isSubmitting}
className="h-11 sm:h-10"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form>
</main>
{/* Deadline footer */}
{submissionDeadline && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:relative sm:static sm:mt-8 sm:pb-3">
<div className="mx-auto max-w-3xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(submissionDeadline).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div>
)
}

View File

@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronUp, FileText } from 'lucide-react'
import { ProjectFilesSection } from './project-files-section'
interface CollapsibleFilesSectionProps {
projectId: string
roundId: string
fileCount: number
}
export function CollapsibleFilesSection({
projectId,
roundId,
fileCount,
}: CollapsibleFilesSectionProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [showFiles, setShowFiles] = useState(false)
const handleToggle = () => {
setIsExpanded(!isExpanded)
// Lazy-load the files when expanding for the first time
if (!isExpanded && !showFiles) {
setShowFiles(true)
}
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">
Project Documents ({fileCount})
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleToggle}
aria-label={isExpanded ? 'Collapse documents' : 'Expand documents'}
className="gap-1"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4" />
<span className="text-sm">Hide</span>
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
<span className="text-sm">Show</span>
</>
)}
</Button>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="pt-0">
{showFiles ? (
<ProjectFilesSection projectId={projectId} roundId={roundId} />
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading documents...
</div>
)}
</CardContent>
)}
</Card>
)
}

View File

@ -0,0 +1,92 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { FileViewer } from '@/components/shared/file-viewer'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertCircle, FileX } from 'lucide-react'
interface ProjectFilesSectionProps {
projectId: string
roundId: string
}
export function ProjectFilesSection({ projectId, roundId }: ProjectFilesSectionProps) {
const { data: groupedFiles, isLoading, error } = trpc.file.listByProjectForRound.useQuery({
projectId,
roundId,
})
if (isLoading) {
return <ProjectFilesSectionSkeleton />
}
if (error) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium text-destructive">Failed to load files</p>
<p className="text-sm text-muted-foreground">
{error.message || 'An error occurred while loading project files'}
</p>
</CardContent>
</Card>
)
}
if (!groupedFiles || groupedFiles.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<FileX className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files available</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Flatten all files from all round groups for FileViewer
const allFiles = groupedFiles.flatMap((group) => group.files)
return (
<div className="space-y-4">
{groupedFiles.map((group) => (
<div key={group.roundId || 'general'} className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.roundName}
</h3>
<div className="flex-1 h-px bg-border" />
</div>
<FileViewer files={group.files} />
</div>
))}
</div>
)
}
function ProjectFilesSectionSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-9 w-20" />
</div>
))}
</CardContent>
</Card>
)
}

View File

@ -34,6 +34,7 @@ import {
User,
LayoutTemplate,
MessageSquare,
Wand2,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
@ -111,6 +112,11 @@ const adminNavigation = [
href: '/admin/programs' as const,
icon: FolderKanban,
},
{
name: 'Apply Settings',
href: '/admin/programs' as const,
icon: Wand2,
},
{
name: 'Audit Log',
href: '/admin/audit' as const,

View File

@ -46,6 +46,8 @@ interface FileUploadProps {
allowedTypes?: string[]
multiple?: boolean
className?: string
roundId?: string
availableRounds?: Array<{ id: string; name: string }>
}
// Map MIME types to suggested file types
@ -83,9 +85,12 @@ export function FileUpload({
allowedTypes,
multiple = true,
className,
roundId,
availableRounds,
}: FileUploadProps) {
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
const [isDragging, setIsDragging] = useState(false)
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(roundId ?? null)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
@ -124,6 +129,7 @@ export function FileUpload({
fileType,
mimeType: file.type || 'application/octet-stream',
size: file.size,
roundId: selectedRoundId ?? undefined,
})
// Store the DB file ID
@ -303,6 +309,31 @@ export function FileUpload({
return (
<div className={cn('space-y-4', className)}>
{/* Round selector */}
{availableRounds && availableRounds.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium">
Upload for Round
</label>
<Select
value={selectedRoundId ?? 'null'}
onValueChange={(value) => setSelectedRoundId(value === 'null' ? null : value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
<SelectItem value="null">General (no specific round)</SelectItem>
{availableRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Drop zone */}
<div
className={cn(

View File

@ -39,10 +39,19 @@ interface ProjectFile {
bucket: string
objectKey: string
version?: number
isLate?: boolean
}
interface RoundGroup {
roundId: string | null
roundName: string
sortOrder: number
files: Array<ProjectFile & { isLate?: boolean }>
}
interface FileViewerProps {
files: ProjectFile[]
files?: ProjectFile[]
groupedFiles?: RoundGroup[]
projectId?: string
className?: string
}
@ -83,8 +92,14 @@ function getFileTypeLabel(fileType: string) {
}
}
export function FileViewer({ files, projectId, className }: FileViewerProps) {
if (files.length === 0) {
export function FileViewer({ files, groupedFiles, projectId, className }: FileViewerProps) {
// Render grouped view if groupedFiles is provided
if (groupedFiles) {
return <GroupedFileViewer groupedFiles={groupedFiles} className={className} />
}
// Render flat view (backward compatible)
if (!files || files.length === 0) {
return (
<Card className={className}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
@ -121,6 +136,68 @@ export function FileViewer({ files, projectId, className }: FileViewerProps) {
)
}
function GroupedFileViewer({ groupedFiles, className }: { groupedFiles: RoundGroup[], className?: string }) {
const hasAnyFiles = groupedFiles.some(group => group.files.length > 0)
if (!hasAnyFiles) {
return (
<Card className={className}>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<File className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No files attached</p>
<p className="text-sm text-muted-foreground">
This project has no files uploaded yet
</p>
</CardContent>
</Card>
)
}
// Sort groups by sortOrder
const sortedGroups = [...groupedFiles].sort((a, b) => a.sortOrder - b.sortOrder)
// Sort files within each group by type order
const fileTypeSortOrder = ['EXEC_SUMMARY', 'BUSINESS_PLAN', 'PRESENTATION', 'VIDEO', 'VIDEO_PITCH', 'SUPPORTING_DOC', 'OTHER']
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-lg">Project Files</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{sortedGroups.map((group) => {
if (group.files.length === 0) return null
const sortedFiles = [...group.files].sort(
(a, b) => fileTypeSortOrder.indexOf(a.fileType) - fileTypeSortOrder.indexOf(b.fileType)
)
return (
<div key={group.roundId || 'no-round'} className="space-y-3">
{/* Round header */}
<div className="flex items-center justify-between border-b pb-2">
<h3 className="font-semibold text-sm text-muted-foreground uppercase tracking-wide">
{group.roundName}
</h3>
<Badge variant="outline" className="text-xs">
{group.files.length} {group.files.length === 1 ? 'file' : 'files'}
</Badge>
</div>
{/* Files in this round */}
<div className="space-y-3">
{sortedFiles.map((file) => (
<FileItem key={file.id} file={file} />
))}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
function FileItem({ file }: { file: ProjectFile }) {
const [showPreview, setShowPreview] = useState(false)
const Icon = getFileIcon(file.fileType, file.mimeType)
@ -151,10 +228,15 @@ function FileItem({ file }: { file: ProjectFile }) {
</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge variant="secondary" className="text-xs">
{getFileTypeLabel(file.fileType)}
</Badge>
{file.isLate && (
<Badge variant="destructive" className="text-xs">
Late
</Badge>
)}
<span>{formatFileSize(file.size)}</span>
</div>
</div>
@ -489,7 +571,7 @@ function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
// Compact file list for smaller views
export function FileList({ files, className }: FileViewerProps) {
if (files.length === 0) return null
if (!files || files.length === 0) return null
return (
<div className={cn('space-y-2', className)}>

137
src/lib/wizard-config.ts Normal file
View File

@ -0,0 +1,137 @@
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)),
}))
}

View File

@ -33,6 +33,8 @@ import { notificationRouter } from './notification'
import { roundTemplateRouter } from './roundTemplate'
import { messageRouter } from './message'
import { webhookRouter } from './webhook'
import { projectPoolRouter } from './project-pool'
import { wizardTemplateRouter } from './wizard-template'
/**
* Root tRPC router that combines all domain routers
@ -72,6 +74,8 @@ export const appRouter = router({
roundTemplate: roundTemplateRouter,
message: messageRouter,
webhook: webhookRouter,
projectPool: projectPoolRouter,
wizardTemplate: wizardTemplateRouter,
})
export type AppRouter = typeof appRouter

View File

@ -195,6 +195,7 @@ export const applicantRouter = router({
// Create new project
const project = await ctx.prisma.project.create({
data: {
programId: roundForCreate.programId,
roundId,
...data,
metadataJson: metadataJson as unknown ?? undefined,

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,17 +51,127 @@ 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 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: {
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.roundSlug },
where: { slug: input.slug },
include: {
program: {
select: {
@ -68,6 +179,7 @@ export const applicationRouter = router({
name: true,
year: true,
description: true,
settingsJson: true,
},
},
},
@ -81,7 +193,6 @@ export const applicationRouter = router({
}
// Check if submissions are open
const now = new Date()
let isOpen = false
if (round.submissionStartDate && round.submissionEndDate) {
@ -101,7 +212,11 @@ export const applicationRouter = router({
}
}
const roundWizardConfig = parseWizardConfig(round.program.settingsJson)
const { settingsJson: _s, ...programData } = round.program
return {
mode: 'round' as const,
round: {
id: round.id,
name: round.name,
@ -115,43 +230,24 @@ export const applicationRouter = router({
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',
},
{
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,18 +261,104 @@ export const applicationRouter = router({
})
}
const { roundId, data } = input
const { mode, programId, roundId, data } = input
// Verify round exists and is open
// Validate input based on mode
if (mode === 'edition' && !programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'programId is required for edition-wide applications',
})
}
if (mode === 'round' && !roundId) {
throw new TRPCError({
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 },
})
const now = new Date()
program = round.program
// Check submission window
let isOpen = false
if (round.submissionStartDate && round.submissionEndDate) {
isOpen = now >= round.submissionStartDate && now <= round.submissionEndDate
@ -214,6 +396,7 @@ export const applicationRouter = router({
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({
@ -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({
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,
}
}),

View File

@ -115,6 +115,7 @@ export const fileRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
mimeType: z.string(),
size: z.number().int().positive(),
roundId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@ -128,6 +129,19 @@ export const fileRouter = router({
})
}
// Calculate isLate flag if roundId is provided
let isLate = false
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingEndAt: true },
})
if (round?.votingEndAt) {
isLate = new Date() > round.votingEndAt
}
}
const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName)
@ -143,6 +157,8 @@ export const fileRouter = router({
size: input.size,
bucket,
objectKey,
roundId: input.roundId,
isLate,
},
})
@ -157,6 +173,8 @@ export const fileRouter = router({
projectId: input.projectId,
fileName: input.fileName,
fileType: input.fileType,
roundId: input.roundId,
isLate,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,

View File

@ -185,6 +185,7 @@ export const notionImportRouter = router({
// Create project
await ctx.prisma.project.create({
data: {
programId: round.programId,
roundId: round.id,
status: 'SUBMITTED',
title: title.trim(),

View File

@ -1,6 +1,9 @@
import { z } from 'zod'
import type { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { wizardConfigSchema } from '@/types/wizard-config'
import { parseWizardConfig } from '@/lib/wizard-config'
export const programRouter = router({
/**
@ -93,8 +96,10 @@ export const programRouter = router({
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
slug: z.string().min(1).max(100).optional(),
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
description: z.string().optional(),
settingsJson: z.record(z.any()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
@ -145,4 +150,66 @@ export const programRouter = router({
return program
}),
/**
* Get wizard config for a program (parsed from settingsJson)
*/
getWizardConfig: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
select: { settingsJson: true },
})
return parseWizardConfig(program.settingsJson)
}),
/**
* Update wizard config for a program (admin only)
*/
updateWizardConfig: adminProcedure
.input(
z.object({
programId: z.string(),
wizardConfig: wizardConfigSchema,
})
)
.mutation(async ({ ctx, input }) => {
const program = await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
select: { settingsJson: true },
})
const currentSettings = (program.settingsJson || {}) as Record<string, unknown>
const updatedSettings = {
...currentSettings,
wizardConfig: input.wizardConfig,
}
await ctx.prisma.program.update({
where: { id: input.programId },
data: {
settingsJson: updatedSettings as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Program',
entityId: input.programId,
detailsJson: {
field: 'wizardConfig',
stepsEnabled: input.wizardConfig.steps.filter((s) => s.enabled).length,
totalSteps: input.wizardConfig.steps.length,
customFieldsCount: input.wizardConfig.customFields?.length ?? 0,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
})

View File

@ -0,0 +1,218 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
/**
* Project Pool Router
*
* Manages the pool of unassigned projects (projects not yet assigned to a round).
* Provides procedures for listing unassigned projects and bulk assigning them to rounds.
*/
export const projectPoolRouter = router({
/**
* List unassigned projects with filtering and pagination
* Projects where roundId IS NULL
*/
listUnassigned: adminProcedure
.input(
z.object({
programId: z.string(), // Required - must specify which program
competitionCategory: z
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
.optional(),
search: z.string().optional(), // Search in title, teamName, description
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(200).default(20),
})
)
.query(async ({ ctx, input }) => {
const { programId, competitionCategory, search, page, perPage } = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {
programId,
roundId: null, // Only unassigned projects
}
// Filter by competition category
if (competitionCategory) {
where.competitionCategory = competitionCategory
}
// Search in title, teamName, description
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
// Execute queries in parallel
const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
teamName: true,
description: true,
competitionCategory: true,
oceanIssue: true,
country: true,
status: true,
submittedAt: true,
createdAt: true,
tags: true,
wantsMentorship: true,
programId: true,
_count: {
select: {
files: true,
teamMembers: true,
},
},
},
}),
ctx.prisma.project.count({ where }),
])
return {
projects,
total,
page,
perPage,
totalPages: Math.ceil(total / perPage),
}
}),
/**
* Bulk assign projects to a round
*
* Validates that:
* - All projects exist
* - All projects belong to the same program as the target round
* - Round exists and belongs to a program
*
* Updates:
* - Project.roundId
* - Project.status to 'ASSIGNED'
* - Creates ProjectStatusHistory records for each project
* - Creates audit log
*/
assignToRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { projectIds, roundId } = input
// Step 1: Fetch round to get programId
const round = await ctx.prisma.round.findUnique({
where: { id: roundId },
select: {
id: true,
programId: true,
name: true,
},
})
if (!round) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Round not found',
})
}
// Step 2: Fetch all projects to validate
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: projectIds },
},
select: {
id: true,
title: true,
programId: true,
},
})
// Validate all projects were found
if (projects.length !== projectIds.length) {
const foundIds = new Set(projects.map((p) => p.id))
const missingIds = projectIds.filter((id) => !foundIds.has(id))
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Some projects were not found: ${missingIds.join(', ')}`,
})
}
// Validate all projects belong to the same program as the round
const invalidProjects = projects.filter(
(p) => p.programId !== round.programId
)
if (invalidProjects.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot assign projects from different programs. The following projects do not belong to the target program: ${invalidProjects
.map((p) => p.title)
.join(', ')}`,
})
}
// Step 3: Perform bulk assignment in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Update all projects
const updatedProjects = await tx.project.updateMany({
where: {
id: { in: projectIds },
},
data: {
roundId: roundId,
status: 'ASSIGNED',
},
})
// Create status history records for each project
await tx.projectStatusHistory.createMany({
data: projectIds.map((projectId) => ({
projectId,
status: 'ASSIGNED',
changedBy: ctx.user?.id,
})),
})
// Create audit log
await logAudit({
prisma: tx,
userId: ctx.user?.id,
action: 'BULK_ASSIGN_TO_ROUND',
entityType: 'Project',
detailsJson: {
roundId,
roundName: round.name,
projectCount: projectIds.length,
projectIds,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updatedProjects
})
return {
success: true,
assignedCount: result.count,
roundId,
roundName: round.name,
}
}),
})

View File

@ -298,10 +298,18 @@ export const projectRouter = router({
)
.mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input
// Get round to fetch programId
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true },
})
const project = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
...rest,
programId: round.programId,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
status: 'SUBMITTED',
},
@ -557,11 +565,12 @@ export const projectRouter = router({
// Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Create all projects with roundId
// Create all projects with roundId and programId
const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
programId: input.programId,
roundId: input.roundId!,
status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,

View File

@ -42,6 +42,23 @@ export const roundRouter = router({
})
}),
/**
* List rounds for a program (alias for list)
*/
listByProgram: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({
where: { programId: input.programId },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
sortOrder: true,
},
})
}),
/**
* Get a single round with stats
*/

View File

@ -213,6 +213,7 @@ export const typeformImportRouter = router({
// Create project
await ctx.prisma.project.create({
data: {
programId: round.programId,
roundId: round.id,
status: 'SUBMITTED',
title: String(title).trim(),

View File

@ -452,7 +452,7 @@ export const userRouter = router({
z.object({
email: z.string().email(),
name: z.string().optional(),
role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
expertiseTags: z.array(z.string()).optional(),
// Optional pre-assignments for jury members
assignments: z
@ -468,6 +468,15 @@ export const userRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Prevent non-super-admins from creating program admins
const hasAdminRole = input.users.some((u) => u.role === 'PROGRAM_ADMIN')
if (hasAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only super admins can create program admins',
})
}
// Deduplicate input by email (keep first occurrence)
const seenEmails = new Set<string>()
const uniqueUsers = input.users.filter((u) => {

View File

@ -0,0 +1,76 @@
import { z } from 'zod'
import type { Prisma } from '@prisma/client'
import { router, adminProcedure } from '../trpc'
import { wizardConfigSchema } from '@/types/wizard-config'
import { logAudit } from '../utils/audit'
export const wizardTemplateRouter = router({
list: adminProcedure
.input(z.object({ programId: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
return ctx.prisma.wizardTemplate.findMany({
where: {
OR: [
{ isGlobal: true },
...(input?.programId ? [{ programId: input.programId }] : []),
],
},
orderBy: { createdAt: 'desc' },
include: { creator: { select: { name: true } } },
})
}),
create: adminProcedure
.input(
z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
config: wizardConfigSchema,
isGlobal: z.boolean().default(false),
programId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const template = await ctx.prisma.wizardTemplate.create({
data: {
name: input.name,
description: input.description,
config: input.config as unknown as Prisma.InputJsonValue,
isGlobal: input.isGlobal,
programId: input.programId,
createdBy: ctx.user.id,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'WizardTemplate',
entityId: template.id,
detailsJson: { name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return template
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.wizardTemplate.delete({ where: { id: input.id } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'WizardTemplate',
entityId: input.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
})

155
src/types/wizard-config.ts Normal file
View File

@ -0,0 +1,155 @@
import { z } from 'zod'
// --- Step Configuration ---
export const WIZARD_STEP_IDS = ['welcome', 'contact', 'project', 'team', 'additional', 'review'] as const
export type WizardStepId = (typeof WIZARD_STEP_IDS)[number]
export const wizardStepSchema = z.object({
id: z.enum(WIZARD_STEP_IDS),
enabled: z.boolean().default(true),
order: z.number().int().min(0),
title: z.string().optional(),
conditionalOn: z
.object({
field: z.string(),
operator: z.enum(['equals', 'notEquals', 'in', 'notIn']),
value: z.union([z.string(), z.array(z.string())]),
})
.optional(),
})
// --- Field Configuration ---
export const wizardFieldValidationSchema = z.object({
min: z.number().optional(),
max: z.number().optional(),
pattern: z.string().optional(),
patternMessage: z.string().optional(),
})
export const wizardFieldConfigSchema = z.object({
required: z.boolean().optional(),
visible: z.boolean().optional(),
label: z.string().optional(),
helpText: z.string().optional(),
placeholder: z.string().optional(),
validation: wizardFieldValidationSchema.optional(),
})
// --- Dropdown Option ---
export const dropdownOptionSchema = z.object({
value: z.string().min(1),
label: z.string().min(1).max(100),
description: z.string().max(300).optional(),
icon: z.string().optional(),
})
// --- Custom Field ---
export const customFieldSchema = z.object({
id: z.string().min(1),
type: z.enum(['text', 'textarea', 'number', 'select', 'multiselect', 'checkbox', 'date']),
label: z.string().min(1).max(100),
placeholder: z.string().optional(),
helpText: z.string().optional(),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
validation: wizardFieldValidationSchema.optional(),
stepId: z.enum(WIZARD_STEP_IDS),
order: z.number().int().default(0),
})
// --- Welcome Message ---
export const welcomeMessageSchema = z.object({
title: z.string().max(200).optional(),
description: z.string().max(1000).optional(),
imageUrl: z.string().url().optional(),
})
// --- Feature Flags ---
export const wizardFeaturesSchema = z.object({
enableWhatsApp: z.boolean().optional(),
enableMentorship: z.boolean().optional(),
enableTeamMembers: z.boolean().optional(),
requireInstitution: z.boolean().optional(),
})
// --- Main Config Schema ---
export const wizardConfigSchema = z.object({
steps: z.array(wizardStepSchema).default([]),
fields: z.record(z.string(), wizardFieldConfigSchema).default({}),
competitionCategories: z.array(dropdownOptionSchema).optional(),
oceanIssues: z.array(dropdownOptionSchema).optional(),
features: wizardFeaturesSchema.optional(),
welcomeMessage: welcomeMessageSchema.optional(),
customFields: z.array(customFieldSchema).optional(),
})
// --- Exported Types ---
export type WizardStep = z.infer<typeof wizardStepSchema>
export type WizardFieldConfig = z.infer<typeof wizardFieldConfigSchema>
export type WizardFieldValidation = z.infer<typeof wizardFieldValidationSchema>
export type DropdownOption = z.infer<typeof dropdownOptionSchema>
export type CustomField = z.infer<typeof customFieldSchema>
export type WizardFeatures = z.infer<typeof wizardFeaturesSchema>
export type WelcomeMessage = z.infer<typeof welcomeMessageSchema>
export type WizardConfig = z.infer<typeof wizardConfigSchema>
// --- Default Configuration ---
// Must match current hardcoded behavior exactly for backward compatibility
export const DEFAULT_COMPETITION_CATEGORIES: DropdownOption[] = [
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concepts',
description: 'For students and recent graduates with innovative ocean-focused business ideas',
icon: 'GraduationCap',
},
{
value: 'STARTUP',
label: 'Start-ups',
description: 'For established companies working on ocean protection solutions',
icon: 'Rocket',
},
]
export const DEFAULT_OCEAN_ISSUES: DropdownOption[] = [
{ 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' },
]
export const DEFAULT_WIZARD_CONFIG: WizardConfig = {
steps: [
{ id: 'welcome', enabled: true, order: 0, title: 'Category' },
{ id: 'contact', enabled: true, order: 1, title: 'Contact' },
{ id: 'project', enabled: true, order: 2, title: 'Project' },
{ id: 'team', enabled: true, order: 3, title: 'Team' },
{ id: 'additional', enabled: true, order: 4, title: 'Details' },
{ id: 'review', enabled: true, order: 5, title: 'Review' },
],
fields: {},
competitionCategories: DEFAULT_COMPETITION_CATEGORIES,
oceanIssues: DEFAULT_OCEAN_ISSUES,
features: {
enableWhatsApp: false,
enableMentorship: true,
enableTeamMembers: true,
requireInstitution: false,
},
customFields: [],
}