Add dynamic apply wizard customization with admin settings UI
Build and Push Docker Image / build (push) Failing after 3m29s
Details
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:
parent
98fe658c33
commit
e7c86a7b1b
|
|
@ -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 | |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}),
|
||||
})
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
Loading…
Reference in New Issue