From e7c86a7b1bcacf2626c83765f4a29bbbf3300a14 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 8 Feb 2026 13:18:20 +0100 Subject: [PATCH] Add dynamic apply wizard customization with admin settings UI - Create wizard config types, utilities, and defaults (wizard-config.ts) - Add admin apply settings page with drag-and-drop step ordering, dropdown option management, feature toggles, welcome message customization, and custom field builder with select/multiselect options editor - Build dynamic apply wizard component with animated step transitions, mobile-first responsive design, and config-driven form validation - Update step components to accept dynamic config (categories, ocean issues, field visibility, feature flags) - Replace hardcoded enum validation with string-based validation for admin-configurable dropdown values, with safe enum casting at storage layer - Add wizard template system (model, router, admin UI) with built-in MOPC Classic preset - Add program wizard config CRUD procedures to program router - Update application router getConfig to return wizardConfig, submit handler to store custom field data in metadataJson - Add edition-based apply page, project pool page, and supporting routers - Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea), safe area insets for notched phones, buildStepsArray field visibility Co-Authored-By: Claude Opus 4.6 --- docs/platform-review.md | 82 +- .../migration.sql | 51 + prisma/schema.prisma | 344 +--- prisma/seed-candidatures.ts | 1 + src/app/(admin)/admin/members/invite/page.tsx | 34 +- .../programs/[id]/apply-settings/page.tsx | 1473 +++++++++++++++++ .../(admin)/admin/programs/[id]/edit/page.tsx | 44 + src/app/(admin)/admin/programs/page.tsx | 13 + src/app/(admin)/admin/projects/[id]/page.tsx | 23 +- src/app/(admin)/admin/projects/pool/page.tsx | 345 ++++ .../jury/projects/[id]/evaluate/page.tsx | 11 + src/app/(jury)/jury/projects/[id]/page.tsx | 7 +- src/app/(public)/apply/[slug]/page.tsx | 420 +---- .../apply/edition/[programSlug]/page.tsx | 65 + .../forms/apply-steps/step-additional.tsx | 58 +- .../forms/apply-steps/step-contact.tsx | 99 +- .../forms/apply-steps/step-project.tsx | 57 +- .../forms/apply-steps/step-review.tsx | 37 +- .../forms/apply-steps/step-team.tsx | 2 + .../forms/apply-steps/step-welcome.tsx | 45 +- src/components/forms/apply-wizard-dynamic.tsx | 806 +++++++++ .../jury/collapsible-files-section.tsx | 76 + src/components/jury/project-files-section.tsx | 92 + src/components/layouts/admin-sidebar.tsx | 6 + src/components/shared/file-upload.tsx | 31 + src/components/shared/file-viewer.tsx | 92 +- src/lib/wizard-config.ts | 137 ++ src/server/routers/_app.ts | 4 + src/server/routers/applicant.ts | 1 + src/server/routers/application.ts | 491 ++++-- src/server/routers/file.ts | 18 + src/server/routers/notion-import.ts | 1 + src/server/routers/program.ts | 67 + src/server/routers/project-pool.ts | 218 +++ src/server/routers/project.ts | 11 +- src/server/routers/round.ts | 17 + src/server/routers/typeform-import.ts | 1 + src/server/routers/user.ts | 11 +- src/server/routers/wizard-template.ts | 76 + src/types/wizard-config.ts | 155 ++ 40 files changed, 4477 insertions(+), 1045 deletions(-) create mode 100644 prisma/migrations/20260207000000_universal_apply_programid/migration.sql create mode 100644 src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx create mode 100644 src/app/(admin)/admin/projects/pool/page.tsx create mode 100644 src/app/(public)/apply/edition/[programSlug]/page.tsx create mode 100644 src/components/forms/apply-wizard-dynamic.tsx create mode 100644 src/components/jury/collapsible-files-section.tsx create mode 100644 src/components/jury/project-files-section.tsx create mode 100644 src/lib/wizard-config.ts create mode 100644 src/server/routers/project-pool.ts create mode 100644 src/server/routers/wizard-template.ts create mode 100644 src/types/wizard-config.ts diff --git a/docs/platform-review.md b/docs/platform-review.md index a70afd8..be67866 100644 --- a/docs/platform-review.md +++ b/docs/platform-review.md @@ -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 | | diff --git a/prisma/migrations/20260207000000_universal_apply_programid/migration.sql b/prisma/migrations/20260207000000_universal_apply_programid/migration.sql new file mode 100644 index 0000000..16a9f44 --- /dev/null +++ b/prisma/migrations/20260207000000_universal_apply_programid/migration.sql @@ -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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2dd106d..665d9e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 @@ -1014,13 +989,12 @@ model LiveVotingSession { } model LiveVote { - id String @id @default(cuid()) - sessionId String - projectId String - userId String - score Int // 1-10 - isAudienceVote Boolean @default(false) // F6: audience voting - votedAt DateTime @default(now()) + id String @id @default(cuid()) + sessionId String + projectId String + userId String + score Int // 1-10 + votedAt DateTime @default(now()) // Relations session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) @@ -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[] + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + mentor User @relation("MentorAssignments", fields: [mentorId], references: [id]) @@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]) -} diff --git a/prisma/seed-candidatures.ts b/prisma/seed-candidatures.ts index 9c4662a..d8f76fd 100644 --- a/prisma/seed-candidatures.ts +++ b/prisma/seed-candidatures.ts @@ -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, diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 03ffb3d..30e0404 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -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 = { + PROGRAM_ADMIN: 'Program Admin', JURY_MEMBER: 'Jury Member', MENTOR: 'Mentor', OBSERVER: 'Observer', @@ -265,6 +266,11 @@ export default function MemberInvitePage() { const [selectedRoundId, setSelectedRoundId] = useState('') 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() { Invite Members Add members individually or upload a CSV file + {isSuperAdmin && ( + + As a super admin, you can also invite program admins + + )} @@ -627,6 +646,11 @@ export default function MemberInvitePage() { + {isSuperAdmin && ( + + Program Admin + + )} Jury Member diff --git a/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx new file mode 100644 index 0000000..2343a59 --- /dev/null +++ b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx @@ -0,0 +1,1473 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { cn } from '@/lib/utils' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import type { + WizardConfig, + WizardStep, + DropdownOption, + CustomField, + WizardStepId, +} from '@/types/wizard-config' +import { DEFAULT_WIZARD_CONFIG, WIZARD_STEP_IDS } from '@/types/wizard-config' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Separator } from '@/components/ui/separator' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + GripVertical, + Eye, + EyeOff, + Save, + Loader2, + Plus, + Pencil, + Trash2, + RotateCcw, + Download, + Upload, +} from 'lucide-react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +// ============================================================================ +// Sortable Step Row +// ============================================================================ + +function SortableStepRow({ + step, + onToggle, + onTitleChange, +}: { + step: WizardStep + onToggle: (id: WizardStepId, enabled: boolean) => void + onTitleChange: (id: WizardStepId, title: string) => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: step.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + const isReview = step.id === 'review' + + return ( +
+ + +
+ onTitleChange(step.id, e.target.value)} + className="h-8 text-sm font-medium" + placeholder={step.id} + /> +
+ + + {step.id} + + + {step.enabled ? ( + + ) : ( + + )} + + onToggle(step.id, checked)} + disabled={isReview} + aria-label={`Toggle ${step.title || step.id}`} + /> +
+ ) +} + +// ============================================================================ +// Sortable Dropdown Option Row +// ============================================================================ + +function SortableOptionRow({ + option, + onEdit, + onDelete, + canDelete, +}: { + option: DropdownOption & { _id: string } + onEdit: () => void + onDelete: () => void + canDelete: boolean +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: option._id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ + +
+

{option.label}

+ {option.description && ( +

+ {option.description} +

+ )} +
+ + + {option.value} + + + + + +
+ ) +} + +// ============================================================================ +// Main Page Component +// ============================================================================ + +export default function ApplySettingsPage() { + const params = useParams<{ id: string }>() + const programId = params.id + + // --- Queries --- + const { data: program, isLoading: programLoading } = trpc.program.get.useQuery( + { id: programId }, + { enabled: !!programId } + ) + const { data: serverConfig, isLoading: configLoading } = + trpc.program.getWizardConfig.useQuery( + { programId }, + { enabled: !!programId } + ) + const { data: templates } = trpc.wizardTemplate.list.useQuery( + { programId }, + { enabled: !!programId } + ) + + // --- Mutations --- + const createTemplate = trpc.wizardTemplate.create.useMutation({ + onSuccess: () => { + toast.success('Template saved') + setSaveTemplateOpen(false) + setSaveTemplateName('') + }, + onError: (error) => toast.error(error.message), + }) + const updateConfig = trpc.program.updateWizardConfig.useMutation({ + onSuccess: () => { + toast.success('Settings saved successfully') + setIsDirty(false) + }, + onError: (error) => { + toast.error(error.message || 'Failed to save settings') + }, + }) + + // --- Local State --- + const [config, setConfig] = useState(DEFAULT_WIZARD_CONFIG) + const [isDirty, setIsDirty] = useState(false) + const [initialized, setInitialized] = useState(false) + + // Dialog states + const [optionDialogOpen, setOptionDialogOpen] = useState(false) + const [optionDialogSection, setOptionDialogSection] = useState< + 'categories' | 'oceanIssues' + >('categories') + const [editingOptionIndex, setEditingOptionIndex] = useState(null) + const [optionForm, setOptionForm] = useState({ + value: '', + label: '', + description: '', + icon: '', + }) + + // Template dialog + const [saveTemplateOpen, setSaveTemplateOpen] = useState(false) + const [saveTemplateName, setSaveTemplateName] = useState('') + + // Custom field dialog + const [fieldDialogOpen, setFieldDialogOpen] = useState(false) + const [fieldForm, setFieldForm] = useState>({ + type: 'text', + label: '', + placeholder: '', + helpText: '', + required: false, + stepId: 'additional', + }) + + // Initialize local state from server data + useEffect(() => { + if (serverConfig && !initialized) { + setConfig(serverConfig) + setInitialized(true) + } + }, [serverConfig, initialized]) + + // --- DnD Sensors --- + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // --- Helper: update config and mark dirty --- + const updateLocalConfig = useCallback( + (updater: (prev: WizardConfig) => WizardConfig) => { + setConfig((prev) => updater(prev)) + setIsDirty(true) + }, + [] + ) + + // ============================================================================ + // Tab 1: Steps handlers + // ============================================================================ + + function handleStepDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id) return + + updateLocalConfig((prev) => { + const steps = [...prev.steps] + const oldIndex = steps.findIndex((s) => s.id === active.id) + const newIndex = steps.findIndex((s) => s.id === over.id) + const reordered = arrayMove(steps, oldIndex, newIndex).map((s, i) => ({ + ...s, + order: i, + })) + return { ...prev, steps: reordered } + }) + } + + function handleStepToggle(id: WizardStepId, enabled: boolean) { + if (id === 'review') return + updateLocalConfig((prev) => ({ + ...prev, + steps: prev.steps.map((s) => (s.id === id ? { ...s, enabled } : s)), + })) + } + + function handleStepTitleChange(id: WizardStepId, title: string) { + updateLocalConfig((prev) => ({ + ...prev, + steps: prev.steps.map((s) => (s.id === id ? { ...s, title } : s)), + })) + } + + // ============================================================================ + // Tab 2: Dropdown Options handlers + // ============================================================================ + + function getOptions(section: 'categories' | 'oceanIssues'): DropdownOption[] { + return section === 'categories' + ? config.competitionCategories || [] + : config.oceanIssues || [] + } + + function setOptions( + section: 'categories' | 'oceanIssues', + options: DropdownOption[] + ) { + updateLocalConfig((prev) => ({ + ...prev, + ...(section === 'categories' + ? { competitionCategories: options } + : { oceanIssues: options }), + })) + } + + function handleOptionDragEnd( + section: 'categories' | 'oceanIssues', + event: DragEndEvent + ) { + const { active, over } = event + if (!over || active.id === over.id) return + + const options = getOptions(section) + const oldIndex = options.findIndex( + (_, i) => `${section}-${i}` === active.id + ) + const newIndex = options.findIndex( + (_, i) => `${section}-${i}` === over.id + ) + if (oldIndex === -1 || newIndex === -1) return + + setOptions(section, arrayMove(options, oldIndex, newIndex)) + } + + function openAddOptionDialog(section: 'categories' | 'oceanIssues') { + setOptionDialogSection(section) + setEditingOptionIndex(null) + setOptionForm({ value: '', label: '', description: '', icon: '' }) + setOptionDialogOpen(true) + } + + function openEditOptionDialog( + section: 'categories' | 'oceanIssues', + index: number + ) { + const options = getOptions(section) + const option = options[index] + setOptionDialogSection(section) + setEditingOptionIndex(index) + setOptionForm({ + value: option.value, + label: option.label, + description: option.description || '', + icon: option.icon || '', + }) + setOptionDialogOpen(true) + } + + function handleSaveOption() { + if (!optionForm.value.trim() || !optionForm.label.trim()) { + toast.error('Value and Label are required') + return + } + + const options = getOptions(optionDialogSection) + + // Clean up optional fields + const cleanOption: DropdownOption = { + value: optionForm.value.trim(), + label: optionForm.label.trim(), + ...(optionForm.description?.trim() + ? { description: optionForm.description.trim() } + : {}), + ...(optionForm.icon?.trim() ? { icon: optionForm.icon.trim() } : {}), + } + + if (editingOptionIndex !== null) { + const updated = [...options] + updated[editingOptionIndex] = cleanOption + setOptions(optionDialogSection, updated) + } else { + setOptions(optionDialogSection, [...options, cleanOption]) + } + + setOptionDialogOpen(false) + } + + function handleDeleteOption( + section: 'categories' | 'oceanIssues', + index: number + ) { + const options = getOptions(section) + if (options.length <= 1) { + toast.error('At least one option is required') + return + } + const option = options[index] + if (section === 'oceanIssues' && option.value === 'OTHER') { + toast.error('The "OTHER" option cannot be deleted') + return + } + setOptions( + section, + options.filter((_, i) => i !== index) + ) + } + + function canDeleteOption( + section: 'categories' | 'oceanIssues', + option: DropdownOption, + totalCount: number + ): boolean { + if (totalCount <= 1) return false + if (section === 'oceanIssues' && option.value === 'OTHER') return false + return true + } + + // ============================================================================ + // Tab 3: Features handlers + // ============================================================================ + + function handleFeatureToggle( + key: keyof NonNullable, + value: boolean + ) { + updateLocalConfig((prev) => ({ + ...prev, + features: { + ...prev.features, + [key]: value, + }, + })) + } + + // ============================================================================ + // Tab 4: Welcome handlers + // ============================================================================ + + function handleWelcomeChange( + field: 'title' | 'description', + value: string + ) { + updateLocalConfig((prev) => ({ + ...prev, + welcomeMessage: { + ...prev.welcomeMessage, + [field]: value || undefined, + }, + })) + } + + // ============================================================================ + // Tab 5: Custom Fields handlers + // ============================================================================ + + function openAddFieldDialog() { + setFieldForm({ + type: 'text', + label: '', + placeholder: '', + helpText: '', + required: false, + stepId: 'additional', + options: [], + }) + setFieldDialogOpen(true) + } + + function handleSaveField() { + if (!fieldForm.label.trim()) { + toast.error('Field label is required') + return + } + + const needsOptions = fieldForm.type === 'select' || fieldForm.type === 'multiselect' + if (needsOptions && (!fieldForm.options || fieldForm.options.length < 2)) { + toast.error('Select fields require at least 2 options') + return + } + + const newField: CustomField = { + id: `custom_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + type: fieldForm.type, + label: fieldForm.label.trim(), + placeholder: fieldForm.placeholder?.trim() || undefined, + helpText: fieldForm.helpText?.trim() || undefined, + required: fieldForm.required, + stepId: fieldForm.stepId, + order: (config.customFields || []).length, + options: needsOptions ? fieldForm.options?.filter(Boolean) : undefined, + } + + updateLocalConfig((prev) => ({ + ...prev, + customFields: [...(prev.customFields || []), newField], + })) + + setFieldDialogOpen(false) + } + + function handleDeleteField(fieldId: string) { + updateLocalConfig((prev) => ({ + ...prev, + customFields: (prev.customFields || []).filter((f) => f.id !== fieldId), + })) + } + + // ============================================================================ + // Save & Reset + // ============================================================================ + + function handleSave() { + updateConfig.mutate({ programId, wizardConfig: config }) + } + + function handleReset() { + setConfig(DEFAULT_WIZARD_CONFIG) + setIsDirty(true) + } + + // ============================================================================ + // Loading State + // ============================================================================ + + if (programLoading || configLoading) { + return ( +
+ + + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ) + } + + // ============================================================================ + // Grouped custom fields for Tab 5 + // ============================================================================ + + const customFieldsByStep = (config.customFields || []).reduce( + (acc, field) => { + const stepId = field.stepId || 'additional' + if (!acc[stepId]) acc[stepId] = [] + acc[stepId].push(field) + return acc + }, + {} as Record + ) + + // ============================================================================ + // Render + // ============================================================================ + + return ( +
+ {/* Breadcrumb */} +
+ + Editions + + / + + {program?.name} {program?.year} + + / + Apply Settings +
+ + {/* Header */} +
+
+
+

+ Apply Wizard Settings +

+ {isDirty && ( + + Unsaved changes + + )} +
+

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

+
+ +
+ {/* Template controls */} + + + + +
+
+ + + + {/* Tabs */} + + + Steps + Dropdown Options + Features + Welcome + Custom Fields + + + {/* ================================================================ */} + {/* Tab 1: Steps */} + {/* ================================================================ */} + + + + Wizard Steps + + Configure and reorder the application wizard steps. Drag to + reorder, toggle visibility, and edit titles. The + "review" step cannot be disabled. + + + + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {config.steps + .sort((a, b) => a.order - b.order) + .map((step) => ( + + ))} +
+
+
+
+
+
+ + {/* ================================================================ */} + {/* Tab 2: Dropdown Options */} + {/* ================================================================ */} + +
+ {/* Competition Categories */} + + +
+ Competition Categories + + Categories applicants can select for their projects + +
+ +
+ + handleOptionDragEnd('categories', e)} + > + `categories-${i}` + )} + strategy={verticalListSortingStrategy} + > +
+ {(config.competitionCategories || []).map( + (option, index) => ( + + openEditOptionDialog('categories', index) + } + onDelete={() => + handleDeleteOption('categories', index) + } + canDelete={canDeleteOption( + 'categories', + option, + (config.competitionCategories || []).length + )} + /> + ) + )} +
+
+
+ {(config.competitionCategories || []).length === 0 && ( +

+ No categories defined. Add at least one. +

+ )} +
+
+ + {/* Ocean Issues */} + + +
+ Ocean Issues + + Ocean-related issues applicants can select. The + "OTHER" option cannot be deleted. + +
+ +
+ + handleOptionDragEnd('oceanIssues', e)} + > + `oceanIssues-${i}` + )} + strategy={verticalListSortingStrategy} + > +
+ {(config.oceanIssues || []).map((option, index) => ( + + openEditOptionDialog('oceanIssues', index) + } + onDelete={() => + handleDeleteOption('oceanIssues', index) + } + canDelete={canDeleteOption( + 'oceanIssues', + option, + (config.oceanIssues || []).length + )} + /> + ))} +
+
+
+ {(config.oceanIssues || []).length === 0 && ( +

+ No ocean issues defined. Add at least one. +

+ )} +
+
+
+
+ + {/* ================================================================ */} + {/* Tab 3: Features */} + {/* ================================================================ */} + + + + Feature Toggles + + Enable or disable optional features in the application wizard + + + +
+
+ +

+ Allow applicants to add team members to their project +

+
+ + handleFeatureToggle('enableTeamMembers', v) + } + /> +
+ + + +
+
+ +

+ Allow applicants to request a mentor during the application +

+
+ + handleFeatureToggle('enableMentorship', v) + } + /> +
+ + + +
+
+ +

+ Collect WhatsApp contact information from applicants +

+
+ + handleFeatureToggle('enableWhatsApp', v) + } + /> +
+ + + +
+
+ +

+ Require applicants to provide their institution or + organization +

+
+ + handleFeatureToggle('requireInstitution', v) + } + /> +
+
+
+
+ + {/* ================================================================ */} + {/* Tab 4: Welcome */} + {/* ================================================================ */} + + + + Welcome Message + + Customize the welcome screen shown at the start of the + application wizard + + + +
+ + handleWelcomeChange('title', e.target.value)} + placeholder="Welcome to the application process" + maxLength={200} + /> +

+ Optional. Max 200 characters. +

+
+ +
+ +