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*
|
*Reviewed by: Feature Proposer Agent*
|
||||||
*Date: 2026-02-05*
|
*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)
|
### 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.
|
**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.
|
**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).
|
**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.
|
**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.
|
**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.
|
**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:
|
**What**: Expand the applicant portal (`/my-submission`) with:
|
||||||
- Application status tracking with timeline (submitted -> under review -> semifinalist -> finalist)
|
- 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.
|
**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
|
### 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.
|
**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).
|
**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 Matrix
|
||||||
|
|
||||||
| Priority | Feature | Impact | Effort |
|
| Priority | Feature | Impact | Effort | Status |
|
||||||
|----------|---------|--------|--------|
|
|----------|---------|--------|--------|--------|
|
||||||
| P0 | 5.1.1 Jury Evaluation Reminders | High | Low |
|
| P0 | 5.1.1 Jury Evaluation Reminders | High | Low | **DONE** |
|
||||||
| P0 | 5.1.2 Evaluation Progress Indicator | High | Very Low |
|
| P0 | 5.1.2 Evaluation Progress Indicator | High | Very Low | **DONE** |
|
||||||
| P0 | 5.1.3 Bulk Project Status Update | High | Low |
|
| P0 | 5.1.3 Bulk Project Status Update | High | Low | **DONE** |
|
||||||
| P0 | 5.1.6 Conflict of Interest Declaration | High | Low |
|
| P0 | 5.1.6 Conflict of Interest Declaration | High | Low | **DONE** |
|
||||||
| P1 | 5.1.4 Export Filtering Results | Medium | Very Low |
|
| P1 | 5.1.4 Export Filtering Results | Medium | Very Low | **DONE** |
|
||||||
| P1 | 5.1.5 Observer Access to Reports | Medium | Low |
|
| P1 | 5.1.5 Observer Access to Reports | Medium | Low | **DONE** |
|
||||||
| P1 | 5.2.1 Email Digest Notifications | High | Medium |
|
| P1 | 5.2.1 Email Digest Notifications | High | Medium | |
|
||||||
| P1 | 5.2.5 Applicant Portal Enhancements | High | Medium |
|
| P1 | 5.2.5 Applicant Portal Enhancements | High | Medium | **DONE** |
|
||||||
| P1 | 5.2.9 AI Evaluation Summary | High | Medium |
|
| P1 | 5.2.9 AI Evaluation Summary | High | Medium | **DONE** |
|
||||||
| P1 | 5.4.2 Evaluation Form Flexibility | High | Medium |
|
| P1 | 5.4.2 Evaluation Form Flexibility | High | Medium | **DONE** |
|
||||||
| P2 | 5.2.2 Evaluation Calibration Tool | Medium | Medium |
|
| P2 | 5.2.2 Evaluation Calibration Tool | Medium | Medium | |
|
||||||
| P2 | 5.2.4 Project Comparison View | Medium | Medium |
|
| P2 | 5.2.4 Project Comparison View | Medium | Medium | |
|
||||||
| P2 | 5.2.6 Round Templates | Medium | Medium |
|
| P2 | 5.2.6 Round Templates | Medium | Medium | |
|
||||||
| P2 | 5.2.7 Jury Availability Preferences | Medium | Medium |
|
| P2 | 5.2.7 Jury Availability Preferences | Medium | Medium | |
|
||||||
| P2 | 5.2.8 Real-Time Live Voting | Medium | Medium |
|
| P2 | 5.2.8 Real-Time Live Voting | Medium | Medium | |
|
||||||
| P2 | 5.4.1 Smart Assignment Improvements | Medium | Low |
|
| P2 | 5.4.1 Smart Assignment Improvements | Medium | Low | **DONE** |
|
||||||
| P2 | 5.4.4 File Management Improvements | Medium | Medium |
|
| P2 | 5.4.4 File Management Improvements | Medium | Medium | |
|
||||||
| P2 | 5.4.5 Live Voting UX Improvements | Medium | Medium |
|
| P2 | 5.4.5 Live Voting UX Improvements | Medium | Medium | |
|
||||||
| P2 | 5.4.6 Mentor Dashboard Enhancements | Medium | Medium |
|
| P2 | 5.4.6 Mentor Dashboard Enhancements | Medium | Medium | |
|
||||||
| P3 | 5.2.3 Multi-Language Support | High | High |
|
| P3 | 5.2.3 Multi-Language Support | High | High | |
|
||||||
| P3 | 5.3.1 Public Website Module | High | High |
|
| P3 | 5.3.1 Public Website Module | High | High | |
|
||||||
| P3 | 5.3.2 Communication Hub | High | High |
|
| P3 | 5.3.2 Communication Hub | High | High | |
|
||||||
| P3 | 5.3.3 Advanced Analytics Dashboard | Medium | High |
|
| P3 | 5.3.3 Advanced Analytics Dashboard | Medium | High | |
|
||||||
| P3 | 5.3.4 Applicant Self-Service Drafts | Medium | Medium |
|
| P3 | 5.3.4 Applicant Self-Service Drafts | Medium | Medium | |
|
||||||
| P3 | 5.3.5 Webhooks & API Integration | Medium | Medium |
|
| P3 | 5.3.5 Webhooks & API Integration | Medium | Medium | |
|
||||||
| P3 | 5.3.6 Peer Review / Collaborative Notes | Medium | High |
|
| P3 | 5.3.6 Peer Review / Collaborative Notes | Medium | High | |
|
||||||
| P3 | 5.4.3 Audit Log Enhancements | Low | Medium |
|
| P3 | 5.4.3 Audit Log Enhancements | Low | Medium | |
|
||||||
| P3 | 5.4.7 Application Form Builder | Medium | High |
|
| 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
|
SECURITY
|
||||||
DEFAULTS
|
DEFAULTS
|
||||||
WHATSAPP
|
WHATSAPP
|
||||||
DIGEST
|
|
||||||
ANALYTICS
|
|
||||||
AUDIT_CONFIG
|
|
||||||
INTEGRATIONS
|
|
||||||
LOCALIZATION
|
|
||||||
COMMUNICATION
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationChannel {
|
enum NotificationChannel {
|
||||||
|
|
@ -216,13 +210,6 @@ model User {
|
||||||
notificationPreference NotificationChannel @default(EMAIL)
|
notificationPreference NotificationChannel @default(EMAIL)
|
||||||
whatsappOptIn Boolean @default(false)
|
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)
|
// Onboarding (Phase 2B)
|
||||||
onboardingCompletedAt DateTime?
|
onboardingCompletedAt DateTime?
|
||||||
|
|
||||||
|
|
@ -282,23 +269,8 @@ model User {
|
||||||
// Mentor messages
|
// Mentor messages
|
||||||
mentorMessages MentorMessage[] @relation("MentorMessageSender")
|
mentorMessages MentorMessage[] @relation("MentorMessageSender")
|
||||||
|
|
||||||
// Digest logs (F1)
|
// Wizard templates
|
||||||
digestLogs DigestLog[]
|
wizardTemplates WizardTemplate[] @relation("WizardTemplateCreatedBy")
|
||||||
|
|
||||||
// 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")
|
|
||||||
|
|
||||||
// NextAuth relations
|
// NextAuth relations
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
|
|
@ -355,6 +327,7 @@ model VerificationToken {
|
||||||
model Program {
|
model Program {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String // e.g., "Monaco Ocean Protection Challenge"
|
name String // e.g., "Monaco Ocean Protection Challenge"
|
||||||
|
slug String? @unique // URL-friendly identifier for edition-wide applications
|
||||||
year Int // e.g., 2026
|
year Int // e.g., 2026
|
||||||
status ProgramStatus @default(DRAFT)
|
status ProgramStatus @default(DRAFT)
|
||||||
description String?
|
description String?
|
||||||
|
|
@ -364,17 +337,35 @@ model Program {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
projects Project[]
|
||||||
rounds Round[]
|
rounds Round[]
|
||||||
learningResources LearningResource[]
|
learningResources LearningResource[]
|
||||||
partners Partner[]
|
partners Partner[]
|
||||||
specialAwards SpecialAward[]
|
specialAwards SpecialAward[]
|
||||||
taggingJobs TaggingJob[]
|
taggingJobs TaggingJob[]
|
||||||
mentorMilestones MentorMilestone[]
|
wizardTemplates WizardTemplate[]
|
||||||
|
|
||||||
@@unique([name, year])
|
@@unique([name, year])
|
||||||
@@index([status])
|
@@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 {
|
model Round {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
programId String
|
programId String
|
||||||
|
|
@ -461,7 +452,8 @@ model EvaluationForm {
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
roundId String
|
programId String
|
||||||
|
roundId String?
|
||||||
status ProjectStatus @default(SUBMITTED)
|
status ProjectStatus @default(SUBMITTED)
|
||||||
|
|
||||||
// Core fields
|
// Core fields
|
||||||
|
|
@ -507,11 +499,6 @@ model Project {
|
||||||
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
|
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
|
||||||
logoProvider String? // Storage provider used: 's3' or 'local'
|
logoProvider String? // Storage provider used: 's3' or 'local'
|
||||||
|
|
||||||
// Draft saving (F11)
|
|
||||||
isDraft Boolean @default(false)
|
|
||||||
draftDataJson Json? @db.JsonB
|
|
||||||
draftExpiresAt DateTime?
|
|
||||||
|
|
||||||
// Flexible fields
|
// Flexible fields
|
||||||
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
|
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
|
||||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||||
|
|
@ -521,7 +508,8 @@ model Project {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// 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[]
|
files ProjectFile[]
|
||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
|
||||||
|
|
@ -535,9 +523,10 @@ model Project {
|
||||||
statusHistory ProjectStatusHistory[]
|
statusHistory ProjectStatusHistory[]
|
||||||
mentorMessages MentorMessage[]
|
mentorMessages MentorMessage[]
|
||||||
evaluationSummaries EvaluationSummary[]
|
evaluationSummaries EvaluationSummary[]
|
||||||
discussions EvaluationDiscussion[]
|
|
||||||
|
|
||||||
|
@@index([programId])
|
||||||
@@index([roundId])
|
@@index([roundId])
|
||||||
|
@@index([programId, roundId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([tags])
|
@@index([tags])
|
||||||
@@index([submissionSource])
|
@@index([submissionSource])
|
||||||
|
|
@ -564,10 +553,6 @@ model ProjectFile {
|
||||||
|
|
||||||
isLate Boolean @default(false) // Uploaded after round deadline
|
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())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
|
@ -717,10 +702,6 @@ model AuditLog {
|
||||||
// Details
|
// Details
|
||||||
detailsJson Json? @db.JsonB // Before/after values, additional context
|
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
|
// Request info
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
|
|
@ -997,12 +978,6 @@ model LiveVotingSession {
|
||||||
votingEndsAt DateTime?
|
votingEndsAt DateTime?
|
||||||
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -1019,7 +994,6 @@ model LiveVote {
|
||||||
projectId String
|
projectId String
|
||||||
userId String
|
userId String
|
||||||
score Int // 1-10
|
score Int // 1-10
|
||||||
isAudienceVote Boolean @default(false) // F6: audience voting
|
|
||||||
votedAt DateTime @default(now())
|
votedAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
|
@ -1030,7 +1004,6 @@ model LiveVote {
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([isAudienceVote])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -1074,15 +1047,9 @@ model MentorAssignment {
|
||||||
expertiseMatchScore Float?
|
expertiseMatchScore Float?
|
||||||
aiReasoning String? @db.Text
|
aiReasoning String? @db.Text
|
||||||
|
|
||||||
// Mentor dashboard enhancements (F8)
|
|
||||||
lastViewedAt DateTime?
|
|
||||||
completionStatus String @default("in_progress") // in_progress, completed, paused
|
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
|
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
|
||||||
notes MentorNote[]
|
|
||||||
milestoneCompletions MentorMilestoneCompletion[]
|
|
||||||
|
|
||||||
@@index([mentorId])
|
@@index([mentorId])
|
||||||
@@index([method])
|
@@index([method])
|
||||||
|
|
@ -1487,242 +1454,3 @@ model MentorMessage {
|
||||||
|
|
||||||
@@index([projectId, createdAt])
|
@@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
|
// Create project
|
||||||
const project = await prisma.project.create({
|
const project = await prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
|
programId: round.programId,
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
title: projectName,
|
title: projectName,
|
||||||
description: row['Comment ']?.trim() || null,
|
description: row['Comment ']?.trim() || null,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ import {
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||||
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
|
|
||||||
interface Assignment {
|
interface Assignment {
|
||||||
projectId: string
|
projectId: string
|
||||||
|
|
@ -99,6 +99,7 @@ interface ParsedUser {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
const ROLE_LABELS: Record<Role, string> = {
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
PROGRAM_ADMIN: 'Program Admin',
|
||||||
JURY_MEMBER: 'Jury Member',
|
JURY_MEMBER: 'Jury Member',
|
||||||
MENTOR: 'Mentor',
|
MENTOR: 'Mentor',
|
||||||
OBSERVER: 'Observer',
|
OBSERVER: 'Observer',
|
||||||
|
|
@ -265,6 +266,11 @@ export default function MemberInvitePage() {
|
||||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
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({
|
const bulkCreate = trpc.user.bulkCreate.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Invalidate user list to refresh the members table when navigating back
|
// 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 name = nameKey ? row[nameKey]?.trim() : undefined
|
||||||
const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : ''
|
const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : ''
|
||||||
const role: Role =
|
const role: Role =
|
||||||
rawRole === 'MENTOR'
|
rawRole === 'PROGRAM_ADMIN'
|
||||||
|
? 'PROGRAM_ADMIN'
|
||||||
|
: rawRole === 'MENTOR'
|
||||||
? 'MENTOR'
|
? 'MENTOR'
|
||||||
: rawRole === 'OBSERVER'
|
: rawRole === 'OBSERVER'
|
||||||
? 'OBSERVER'
|
? 'OBSERVER'
|
||||||
: 'JURY_MEMBER'
|
: 'JURY_MEMBER'
|
||||||
const isValidFormat = emailRegex.test(email)
|
const isValidFormat = emailRegex.test(email)
|
||||||
const isDuplicate = email ? seenEmails.has(email) : false
|
const isDuplicate = email ? seenEmails.has(email) : false
|
||||||
|
const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin
|
||||||
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
|
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
|
||||||
return {
|
return {
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
role,
|
role,
|
||||||
isValid: isValidFormat && !isDuplicate,
|
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
|
||||||
isDuplicate,
|
isDuplicate,
|
||||||
error: !email
|
error: !email
|
||||||
? 'No email found'
|
? 'No email found'
|
||||||
|
|
@ -413,6 +422,8 @@ export default function MemberInvitePage() {
|
||||||
? 'Invalid email format'
|
? 'Invalid email format'
|
||||||
: isDuplicate
|
: isDuplicate
|
||||||
? 'Duplicate email'
|
? 'Duplicate email'
|
||||||
|
: isUnauthorizedAdmin
|
||||||
|
? 'Only super admins can invite program admins'
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -421,7 +432,7 @@ export default function MemberInvitePage() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[]
|
[isSuperAdmin]
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Parse manual rows into ParsedUser format ---
|
// --- Parse manual rows into ParsedUser format ---
|
||||||
|
|
@ -433,6 +444,7 @@ export default function MemberInvitePage() {
|
||||||
const email = r.email.trim().toLowerCase()
|
const email = r.email.trim().toLowerCase()
|
||||||
const isValidFormat = emailRegex.test(email)
|
const isValidFormat = emailRegex.test(email)
|
||||||
const isDuplicate = seenEmails.has(email)
|
const isDuplicate = seenEmails.has(email)
|
||||||
|
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin
|
||||||
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
||||||
return {
|
return {
|
||||||
email,
|
email,
|
||||||
|
|
@ -440,12 +452,14 @@ export default function MemberInvitePage() {
|
||||||
role: r.role,
|
role: r.role,
|
||||||
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
|
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
|
||||||
assignments: r.assignments.length > 0 ? r.assignments : undefined,
|
assignments: r.assignments.length > 0 ? r.assignments : undefined,
|
||||||
isValid: isValidFormat && !isDuplicate,
|
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
|
||||||
isDuplicate,
|
isDuplicate,
|
||||||
error: !isValidFormat
|
error: !isValidFormat
|
||||||
? 'Invalid email format'
|
? 'Invalid email format'
|
||||||
: isDuplicate
|
: isDuplicate
|
||||||
? 'Duplicate email'
|
? 'Duplicate email'
|
||||||
|
: isUnauthorizedAdmin
|
||||||
|
? 'Only super admins can invite program admins'
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -524,6 +538,11 @@ export default function MemberInvitePage() {
|
||||||
<CardTitle>Invite Members</CardTitle>
|
<CardTitle>Invite Members</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add members individually or upload a CSV file
|
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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
|
@ -627,6 +646,11 @@ export default function MemberInvitePage() {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<SelectItem value="PROGRAM_ADMIN">
|
||||||
|
Program Admin
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
<SelectItem value="JURY_MEMBER">
|
<SelectItem value="JURY_MEMBER">
|
||||||
Jury Member
|
Jury Member
|
||||||
</SelectItem>
|
</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 [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
|
slug: '',
|
||||||
description: '',
|
description: '',
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
|
applyMode: 'round' as 'edition' | 'round' | 'both',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
|
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (program) {
|
if (program) {
|
||||||
|
const settings = (program.settingsJson as Record<string, any>) || {}
|
||||||
setFormData({
|
setFormData({
|
||||||
name: program.name,
|
name: program.name,
|
||||||
|
slug: program.slug || '',
|
||||||
description: program.description || '',
|
description: program.description || '',
|
||||||
status: program.status,
|
status: program.status,
|
||||||
|
applyMode: settings.applyMode || 'round',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [program])
|
}, [program])
|
||||||
|
|
@ -89,8 +94,12 @@ export default function EditProgramPage() {
|
||||||
updateProgram.mutate({
|
updateProgram.mutate({
|
||||||
id,
|
id,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
|
slug: formData.slug || undefined,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
|
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
|
||||||
|
settingsJson: {
|
||||||
|
applyMode: formData.applyMode,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,6 +205,41 @@ export default function EditProgramPage() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Eye,
|
Eye,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Wand2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
|
|
@ -146,6 +147,12 @@ async function ProgramsContent() {
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/admin/programs/${program.id}/apply-settings`}>
|
||||||
|
<Wand2 className="mr-2 h-4 w-4" />
|
||||||
|
Apply Settings
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -194,6 +201,12 @@ async function ProgramsContent() {
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch files
|
// Fetch files (flat list for backward compatibility)
|
||||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
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
|
// Fetch assignments
|
||||||
const { data: assignments } = trpc.assignment.listByProject.useQuery({
|
const { data: assignments } = trpc.assignment.listByProject.useQuery({
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -492,7 +504,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{files && files.length > 0 ? (
|
{groupedFiles && groupedFiles.length > 0 ? (
|
||||||
|
<FileViewer groupedFiles={groupedFiles} />
|
||||||
|
) : files && files.length > 0 ? (
|
||||||
<FileViewer
|
<FileViewer
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
files={files.map((f) => ({
|
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>
|
<p className="text-sm font-medium mb-3">Upload New Files</p>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
roundId={project.roundId || undefined}
|
||||||
|
availableRounds={rounds?.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name }))}
|
||||||
onUploadComplete={() => {
|
onUploadComplete={() => {
|
||||||
utils.file.listByProject.invalidate({ projectId })
|
utils.file.listByProject.invalidate({ projectId })
|
||||||
|
if (project.roundId) {
|
||||||
|
utils.file.listByProjectForRound.invalidate({ projectId, roundId: project.roundId })
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
|
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 { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
|
||||||
import { isFuture, isPast } from 'date-fns'
|
import { isFuture, isPast } from 'date-fns'
|
||||||
|
|
||||||
|
|
@ -76,6 +77,9 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
include: {
|
include: {
|
||||||
files: true,
|
files: true,
|
||||||
|
_count: {
|
||||||
|
select: { files: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -266,6 +270,13 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Project Files */}
|
||||||
|
<CollapsibleFilesSection
|
||||||
|
projectId={project.id}
|
||||||
|
roundId={round.id}
|
||||||
|
fileCount={project._count?.files || 0}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Evaluation Form with COI Gate */}
|
{/* Evaluation Form with COI Gate */}
|
||||||
<EvaluationFormWithCOI
|
<EvaluationFormWithCOI
|
||||||
assignmentId={assignment.id}
|
assignmentId={assignment.id}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
|
@ -255,7 +256,9 @@ async function ProjectContent({ projectId }: { projectId: string }) {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Files */}
|
{/* Files */}
|
||||||
<FileViewer files={project.files} />
|
<Suspense fallback={<FileViewerSkeleton />}>
|
||||||
|
<ProjectFilesSection projectId={project.id} roundId={assignment.roundId} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
|
||||||
|
|
@ -1,423 +1,65 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
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 { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
|
||||||
import {
|
import { Loader2, AlertCircle } from 'lucide-react'
|
||||||
Waves,
|
|
||||||
AlertCircle,
|
|
||||||
Loader2,
|
|
||||||
CheckCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Clock,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
|
||||||
import {
|
import { toast } from 'sonner'
|
||||||
StepWelcome,
|
|
||||||
StepContact,
|
|
||||||
StepProject,
|
|
||||||
StepTeam,
|
|
||||||
StepAdditional,
|
|
||||||
StepReview,
|
|
||||||
} from '@/components/forms/apply-steps'
|
|
||||||
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
// Form validation schema
|
export default function RoundApplyPage() {
|
||||||
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() {
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const slug = params.slug as string
|
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(
|
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
|
||||||
{ roundSlug: slug },
|
{ slug, mode: 'round' },
|
||||||
{ retry: false }
|
{ retry: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitMutation = trpc.application.submit.useMutation({
|
const submitMutation = trpc.application.submit.useMutation({
|
||||||
onSuccess: (result) => {
|
onError: (error) => toast.error(error.message),
|
||||||
setSubmitted(true)
|
|
||||||
setSubmissionMessage(result.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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-2xl space-y-6">
|
|
||||||
<div className="flex items-center justify-center gap-3">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<span className="text-lg text-muted-foreground">Loading application...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
if (error || !config || config.mode !== 'round') {
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
|
||||||
<div className="w-full max-w-md text-center">
|
<div className="text-center">
|
||||||
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
|
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
|
||||||
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
|
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
|
||||||
<p className="text-muted-foreground mb-6">{error.message}</p>
|
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
|
||||||
<Button variant="outline" onClick={() => router.push('/')}>
|
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
|
||||||
Return Home
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applications closed state
|
|
||||||
if (config && !config.round.isOpen) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
|
<ApplyWizardDynamic
|
||||||
<div className="w-full max-w-md text-center">
|
mode="round"
|
||||||
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
|
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
|
||||||
<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
|
|
||||||
programName={config.program.name}
|
programName={config.program.name}
|
||||||
programYear={config.program.year}
|
programYear={config.program.year}
|
||||||
value={competitionCategory}
|
roundId={config.round.id}
|
||||||
onChange={(value) => form.setValue('competitionCategory', value)}
|
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 { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import type { ApplicationFormData } from '@/server/routers/application'
|
import type { ApplicationFormData } from '@/server/routers/application'
|
||||||
|
import type { WizardConfig } from '@/types/wizard-config'
|
||||||
|
|
||||||
interface StepAdditionalProps {
|
interface StepAdditionalProps {
|
||||||
form: UseFormReturn<ApplicationFormData>
|
form: UseFormReturn<ApplicationFormData>
|
||||||
isBusinessConcept: boolean
|
isBusinessConcept: boolean
|
||||||
isStartup: 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 { register, formState: { errors }, setValue, watch } = form
|
||||||
const wantsMentorship = watch('wantsMentorship')
|
const wantsMentorship = watch('wantsMentorship')
|
||||||
|
|
||||||
|
|
@ -86,6 +88,7 @@ export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAddit
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mentorship */}
|
{/* Mentorship */}
|
||||||
|
{config?.features?.enableMentorship !== false && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -114,6 +117,7 @@ export function StepAdditional({ form, isBusinessConcept, isStartup }: StepAddit
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Referral Source */}
|
{/* Referral Source */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,27 @@ import { Label } from '@/components/ui/label'
|
||||||
import { PhoneInput } from '@/components/ui/phone-input'
|
import { PhoneInput } from '@/components/ui/phone-input'
|
||||||
import { CountrySelect } from '@/components/ui/country-select'
|
import { CountrySelect } from '@/components/ui/country-select'
|
||||||
import type { ApplicationFormData } from '@/server/routers/application'
|
import type { ApplicationFormData } from '@/server/routers/application'
|
||||||
|
import type { WizardConfig } from '@/types/wizard-config'
|
||||||
|
import { isFieldVisible, isFieldRequired, getFieldConfig } from '@/lib/wizard-config'
|
||||||
|
|
||||||
interface StepContactProps {
|
interface StepContactProps {
|
||||||
form: UseFormReturn<ApplicationFormData>
|
form: UseFormReturn<ApplicationFormData>
|
||||||
|
config?: WizardConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepContact({ form }: StepContactProps) {
|
export function StepContact({ form, config }: StepContactProps) {
|
||||||
const { register, formState: { errors }, setValue, watch } = form
|
const { register, formState: { errors }, setValue, watch } = form
|
||||||
const country = watch('country')
|
const country = watch('country')
|
||||||
const phone = watch('contactPhone')
|
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 (
|
return (
|
||||||
<WizardStepContent
|
<WizardStepContent
|
||||||
title="Tell us about yourself"
|
title="Tell us about yourself"
|
||||||
|
|
@ -62,9 +73,11 @@ export function StepContact({ form }: StepContactProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
|
{showPhone && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="contactPhone">
|
<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>
|
</Label>
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
value={phone}
|
value={phone}
|
||||||
|
|
@ -76,11 +89,14 @@ export function StepContact({ form }: StepContactProps) {
|
||||||
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
|
<p className="text-sm text-destructive">{errors.contactPhone.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Country */}
|
{/* Country */}
|
||||||
|
{showCountry && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<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>
|
</Label>
|
||||||
<CountrySelect
|
<CountrySelect
|
||||||
value={country}
|
value={country}
|
||||||
|
|
@ -92,8 +108,10 @@ export function StepContact({ form }: StepContactProps) {
|
||||||
<p className="text-sm text-destructive">{errors.country.message}</p>
|
<p className="text-sm text-destructive">{errors.country.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* City (optional) */}
|
{/* City (optional) */}
|
||||||
|
{showCity && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="city">
|
<Label htmlFor="city">
|
||||||
City <span className="text-muted-foreground text-xs">(optional)</span>
|
City <span className="text-muted-foreground text-xs">(optional)</span>
|
||||||
|
|
@ -105,6 +123,7 @@ export function StepContact({ form }: StepContactProps) {
|
||||||
className="h-12 text-base"
|
className="h-12 text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</WizardStepContent>
|
</WizardStepContent>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,35 +14,22 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import type { ApplicationFormData } from '@/server/routers/application'
|
import type { ApplicationFormData } from '@/server/routers/application'
|
||||||
import { OceanIssue } from '@prisma/client'
|
import { type DropdownOption, type WizardConfig, DEFAULT_OCEAN_ISSUES } from '@/types/wizard-config'
|
||||||
|
import { isFieldVisible, getFieldConfig } from '@/lib/wizard-config'
|
||||||
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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface StepProjectProps {
|
interface StepProjectProps {
|
||||||
form: UseFormReturn<ApplicationFormData>
|
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 { register, formState: { errors }, setValue, watch } = form
|
||||||
const oceanIssue = watch('oceanIssue')
|
const oceanIssue = watch('oceanIssue')
|
||||||
const description = watch('description') || ''
|
const description = watch('description') || ''
|
||||||
|
const showTeamName = !config || isFieldVisible(config, 'teamName')
|
||||||
|
const descriptionLabel = config ? getFieldConfig(config, 'description').label : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardStepContent
|
<WizardStepContent
|
||||||
|
|
@ -71,6 +58,7 @@ export function StepProject({ form }: StepProjectProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Name (optional) */}
|
{/* Team Name (optional) */}
|
||||||
|
{showTeamName && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="teamName">
|
<Label htmlFor="teamName">
|
||||||
Team Name <span className="text-muted-foreground text-xs">(optional)</span>
|
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"
|
className="h-12 text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ocean Issue */}
|
{/* Ocean Issue */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -90,13 +79,13 @@ export function StepProject({ form }: StepProjectProps) {
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={oceanIssue}
|
value={oceanIssue}
|
||||||
onValueChange={(value) => setValue('oceanIssue', value as OceanIssue)}
|
onValueChange={(value) => setValue('oceanIssue', value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-12 text-base">
|
<SelectTrigger className="h-12 text-base">
|
||||||
<SelectValue placeholder="Select an ocean issue" />
|
<SelectValue placeholder="Select an ocean issue" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{oceanIssueOptions.map((option) => (
|
{issueOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -22,37 +22,30 @@ import { Label } from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import type { ApplicationFormData } from '@/server/routers/application'
|
import type { ApplicationFormData } from '@/server/routers/application'
|
||||||
import { countries } from '@/components/ui/country-select'
|
import { countries } from '@/components/ui/country-select'
|
||||||
|
import { type WizardConfig, DEFAULT_OCEAN_ISSUES, DEFAULT_COMPETITION_CATEGORIES } from '@/types/wizard-config'
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepReviewProps {
|
interface StepReviewProps {
|
||||||
form: UseFormReturn<ApplicationFormData>
|
form: UseFormReturn<ApplicationFormData>
|
||||||
programName: string
|
programName: string
|
||||||
|
config?: WizardConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepReview({ form, programName }: StepReviewProps) {
|
export function StepReview({ form, programName, config }: StepReviewProps) {
|
||||||
const { formState: { errors }, setValue, watch } = form
|
const { formState: { errors }, setValue, watch } = form
|
||||||
const data = watch()
|
const data = watch()
|
||||||
|
|
||||||
const countryName = countries.find((c) => c.code === data.country)?.name || data.country
|
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 (
|
return (
|
||||||
<WizardStepContent
|
<WizardStepContent
|
||||||
title="Review your application"
|
title="Review your application"
|
||||||
|
|
@ -108,12 +101,12 @@ export function StepReview({ form, programName }: StepReviewProps) {
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{categoryLabels[data.competitionCategory]}
|
{getCategoryLabel(data.competitionCategory)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Waves className="h-4 w-4 text-muted-foreground" />
|
<Waves className="h-4 w-4 text-muted-foreground" />
|
||||||
<span>{oceanIssueLabels[data.oceanIssue]}</span>
|
<span>{getOceanIssueLabel(data.oceanIssue)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Description:</span>
|
<span className="text-muted-foreground">Description:</span>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import type { ApplicationFormData } from '@/server/routers/application'
|
import type { ApplicationFormData } from '@/server/routers/application'
|
||||||
import { TeamMemberRole } from '@prisma/client'
|
import { TeamMemberRole } from '@prisma/client'
|
||||||
|
import type { WizardConfig } from '@/types/wizard-config'
|
||||||
|
|
||||||
const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
||||||
{ value: 'MEMBER', label: 'Team Member' },
|
{ value: 'MEMBER', label: 'Team Member' },
|
||||||
|
|
@ -25,6 +26,7 @@ const roleOptions: { value: TeamMemberRole; label: string }[] = [
|
||||||
|
|
||||||
interface StepTeamProps {
|
interface StepTeamProps {
|
||||||
form: UseFormReturn<ApplicationFormData>
|
form: UseFormReturn<ApplicationFormData>
|
||||||
|
config?: WizardConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepTeam({ form }: StepTeamProps) {
|
export function StepTeam({ form }: StepTeamProps) {
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,28 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { motion } from 'motion/react'
|
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 { cn } from '@/lib/utils'
|
||||||
import { WizardStepContent } from '@/components/forms/form-wizard'
|
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 {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
value: CompetitionCategory
|
GraduationCap,
|
||||||
label: string
|
Rocket,
|
||||||
description: string
|
|
||||||
icon: typeof 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 {
|
interface StepWelcomeProps {
|
||||||
programName: string
|
programName: string
|
||||||
programYear: number
|
programYear: number
|
||||||
value: CompetitionCategory | null
|
value: string | null
|
||||||
onChange: (value: CompetitionCategory) => void
|
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 (
|
return (
|
||||||
<WizardStepContent>
|
<WizardStepContent>
|
||||||
<div className="flex flex-col items-center text-center">
|
<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 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
|
||||||
{programName}
|
{welcomeMessage?.title ?? programName}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-xl text-primary font-semibold">
|
<p className="mt-2 text-xl text-primary font-semibold">
|
||||||
{programYear} Application
|
{programYear} Application
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 max-w-md text-muted-foreground">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|
@ -75,8 +62,8 @@ export function StepWelcome({ programName, programYear, value, onChange }: StepW
|
||||||
transition={{ delay: 0.4 }}
|
transition={{ delay: 0.4 }}
|
||||||
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
|
className="mt-10 grid w-full max-w-2xl gap-4 md:grid-cols-2"
|
||||||
>
|
>
|
||||||
{categories.map((category) => {
|
{categoryOptions.map((category) => {
|
||||||
const Icon = category.icon
|
const Icon = (category.icon ? ICON_MAP[category.icon] : undefined) ?? Waves
|
||||||
const isSelected = value === category.value
|
const isSelected = value === category.value
|
||||||
|
|
||||||
return (
|
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,
|
User,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Wand2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getInitials } from '@/lib/utils'
|
import { getInitials } from '@/lib/utils'
|
||||||
import { Logo } from '@/components/shared/logo'
|
import { Logo } from '@/components/shared/logo'
|
||||||
|
|
@ -111,6 +112,11 @@ const adminNavigation = [
|
||||||
href: '/admin/programs' as const,
|
href: '/admin/programs' as const,
|
||||||
icon: FolderKanban,
|
icon: FolderKanban,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Apply Settings',
|
||||||
|
href: '/admin/programs' as const,
|
||||||
|
icon: Wand2,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Audit Log',
|
name: 'Audit Log',
|
||||||
href: '/admin/audit' as const,
|
href: '/admin/audit' as const,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ interface FileUploadProps {
|
||||||
allowedTypes?: string[]
|
allowedTypes?: string[]
|
||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
roundId?: string
|
||||||
|
availableRounds?: Array<{ id: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map MIME types to suggested file types
|
// Map MIME types to suggested file types
|
||||||
|
|
@ -83,9 +85,12 @@ export function FileUpload({
|
||||||
allowedTypes,
|
allowedTypes,
|
||||||
multiple = true,
|
multiple = true,
|
||||||
className,
|
className,
|
||||||
|
roundId,
|
||||||
|
availableRounds,
|
||||||
}: FileUploadProps) {
|
}: FileUploadProps) {
|
||||||
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([])
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(roundId ?? null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
|
const getUploadUrl = trpc.file.getUploadUrl.useMutation()
|
||||||
|
|
@ -124,6 +129,7 @@ export function FileUpload({
|
||||||
fileType,
|
fileType,
|
||||||
mimeType: file.type || 'application/octet-stream',
|
mimeType: file.type || 'application/octet-stream',
|
||||||
size: file.size,
|
size: file.size,
|
||||||
|
roundId: selectedRoundId ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store the DB file ID
|
// Store the DB file ID
|
||||||
|
|
@ -303,6 +309,31 @@ export function FileUpload({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-4', className)}>
|
<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 */}
|
{/* Drop zone */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,19 @@ interface ProjectFile {
|
||||||
bucket: string
|
bucket: string
|
||||||
objectKey: string
|
objectKey: string
|
||||||
version?: number
|
version?: number
|
||||||
|
isLate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoundGroup {
|
||||||
|
roundId: string | null
|
||||||
|
roundName: string
|
||||||
|
sortOrder: number
|
||||||
|
files: Array<ProjectFile & { isLate?: boolean }>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileViewerProps {
|
interface FileViewerProps {
|
||||||
files: ProjectFile[]
|
files?: ProjectFile[]
|
||||||
|
groupedFiles?: RoundGroup[]
|
||||||
projectId?: string
|
projectId?: string
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
@ -83,8 +92,14 @@ function getFileTypeLabel(fileType: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileViewer({ files, projectId, className }: FileViewerProps) {
|
export function FileViewer({ files, groupedFiles, projectId, className }: FileViewerProps) {
|
||||||
if (files.length === 0) {
|
// 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 (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<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 }) {
|
function FileItem({ file }: { file: ProjectFile }) {
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
const Icon = getFileIcon(file.fileType, file.mimeType)
|
const Icon = getFileIcon(file.fileType, file.mimeType)
|
||||||
|
|
@ -151,10 +228,15 @@ function FileItem({ file }: { file: ProjectFile }) {
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{getFileTypeLabel(file.fileType)}
|
{getFileTypeLabel(file.fileType)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{file.isLate && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
Late
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<span>{formatFileSize(file.size)}</span>
|
<span>{formatFileSize(file.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -489,7 +571,7 @@ function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
|
||||||
|
|
||||||
// Compact file list for smaller views
|
// Compact file list for smaller views
|
||||||
export function FileList({ files, className }: FileViewerProps) {
|
export function FileList({ files, className }: FileViewerProps) {
|
||||||
if (files.length === 0) return null
|
if (!files || files.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-2', className)}>
|
<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 { roundTemplateRouter } from './roundTemplate'
|
||||||
import { messageRouter } from './message'
|
import { messageRouter } from './message'
|
||||||
import { webhookRouter } from './webhook'
|
import { webhookRouter } from './webhook'
|
||||||
|
import { projectPoolRouter } from './project-pool'
|
||||||
|
import { wizardTemplateRouter } from './wizard-template'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root tRPC router that combines all domain routers
|
* Root tRPC router that combines all domain routers
|
||||||
|
|
@ -72,6 +74,8 @@ export const appRouter = router({
|
||||||
roundTemplate: roundTemplateRouter,
|
roundTemplate: roundTemplateRouter,
|
||||||
message: messageRouter,
|
message: messageRouter,
|
||||||
webhook: webhookRouter,
|
webhook: webhookRouter,
|
||||||
|
projectPool: projectPoolRouter,
|
||||||
|
wizardTemplate: wizardTemplateRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,7 @@ export const applicantRouter = router({
|
||||||
// Create new project
|
// Create new project
|
||||||
const project = await ctx.prisma.project.create({
|
const project = await ctx.prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
|
programId: roundForCreate.programId,
|
||||||
roundId,
|
roundId,
|
||||||
...data,
|
...data,
|
||||||
metadataJson: metadataJson as unknown ?? undefined,
|
metadataJson: metadataJson as unknown ?? undefined,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from '../services/in-app-notification'
|
} from '../services/in-app-notification'
|
||||||
import { checkRateLimit } from '@/lib/rate-limit'
|
import { checkRateLimit } from '@/lib/rate-limit'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
|
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||||
|
|
||||||
// Zod schemas for the application form
|
// Zod schemas for the application form
|
||||||
const teamMemberSchema = z.object({
|
const teamMemberSchema = z.object({
|
||||||
|
|
@ -19,8 +20,8 @@ const teamMemberSchema = z.object({
|
||||||
})
|
})
|
||||||
|
|
||||||
const applicationSchema = z.object({
|
const applicationSchema = z.object({
|
||||||
// Step 1: Category
|
// Step 1: Category (string to support admin-configured custom values)
|
||||||
competitionCategory: z.nativeEnum(CompetitionCategory),
|
competitionCategory: z.string().min(1, 'Competition category is required'),
|
||||||
|
|
||||||
// Step 2: Contact Info
|
// Step 2: Contact Info
|
||||||
contactName: z.string().min(2, 'Full name is required'),
|
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'),
|
country: z.string().min(2, 'Country is required'),
|
||||||
city: z.string().optional(),
|
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),
|
projectName: z.string().min(2, 'Project name is required').max(200),
|
||||||
teamName: z.string().optional(),
|
teamName: z.string().optional(),
|
||||||
description: z.string().min(20, 'Description must be at least 20 characters'),
|
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
|
// Step 4: Team Members
|
||||||
teamMembers: z.array(teamMemberSchema).optional(),
|
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>
|
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({
|
export const applicationRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get application configuration for a round
|
* Get application configuration for a round or edition
|
||||||
*/
|
*/
|
||||||
getConfig: publicProcedure
|
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 }) => {
|
.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({
|
const round = await ctx.prisma.round.findFirst({
|
||||||
where: { slug: input.roundSlug },
|
where: { slug: input.slug },
|
||||||
include: {
|
include: {
|
||||||
program: {
|
program: {
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -68,6 +179,7 @@ export const applicationRouter = router({
|
||||||
name: true,
|
name: true,
|
||||||
year: true,
|
year: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
settingsJson: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -81,7 +193,6 @@ export const applicationRouter = router({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if submissions are open
|
// Check if submissions are open
|
||||||
const now = new Date()
|
|
||||||
let isOpen = false
|
let isOpen = false
|
||||||
|
|
||||||
if (round.submissionStartDate && round.submissionEndDate) {
|
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 {
|
return {
|
||||||
|
mode: 'round' as const,
|
||||||
round: {
|
round: {
|
||||||
id: round.id,
|
id: round.id,
|
||||||
name: round.name,
|
name: round.name,
|
||||||
|
|
@ -115,43 +230,24 @@ export const applicationRouter = router({
|
||||||
phase2Deadline: round.phase2Deadline,
|
phase2Deadline: round.phase2Deadline,
|
||||||
isOpen,
|
isOpen,
|
||||||
},
|
},
|
||||||
program: round.program,
|
program: programData,
|
||||||
oceanIssueOptions: [
|
wizardConfig: roundWizardConfig,
|
||||||
{ value: 'POLLUTION_REDUCTION', label: 'Reduction of pollution (plastics, chemicals, noise, light,...)' },
|
oceanIssueOptions: roundWizardConfig.oceanIssues ?? [],
|
||||||
{ value: 'CLIMATE_MITIGATION', label: 'Mitigation of climate change and sea-level rise' },
|
competitionCategories: roundWizardConfig.competitionCategories ?? [],
|
||||||
{ 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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a new application
|
* Submit a new application (edition-wide or round-specific)
|
||||||
*/
|
*/
|
||||||
submit: publicProcedure
|
submit: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
mode: z.enum(['edition', 'round']).default('round'),
|
||||||
data: applicationSchema,
|
programId: z.string().optional(),
|
||||||
|
roundId: z.string().optional(),
|
||||||
|
data: applicationInputSchema,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
include: { program: true },
|
include: { program: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const now = new Date()
|
program = round.program
|
||||||
|
|
||||||
// Check submission window
|
// Check submission window
|
||||||
let isOpen = false
|
|
||||||
if (round.submissionStartDate && round.submissionEndDate) {
|
if (round.submissionStartDate && round.submissionEndDate) {
|
||||||
isOpen = now >= round.submissionStartDate && now <= 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',
|
message: 'An application with this email already exists for this round',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user exists, or create a new applicant user
|
// Check if user exists, or create a new applicant user
|
||||||
let user = await ctx.prisma.user.findUnique({
|
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
|
// Create the project
|
||||||
const project = await ctx.prisma.project.create({
|
const project = await ctx.prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
roundId,
|
programId: program.id,
|
||||||
|
roundId: mode === 'round' ? roundId! : null,
|
||||||
title: data.projectName,
|
title: data.projectName,
|
||||||
teamName: data.teamName,
|
teamName: data.teamName,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
competitionCategory: data.competitionCategory,
|
competitionCategory: categoryEnum,
|
||||||
oceanIssue: data.oceanIssue,
|
oceanIssue: oceanIssueEnum,
|
||||||
country: data.country,
|
country: data.country,
|
||||||
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
|
geographicZone: data.city ? `${data.city}, ${data.country}` : data.country,
|
||||||
institution: data.institution,
|
institution: data.institution,
|
||||||
|
|
@ -254,6 +448,12 @@ export const applicationRouter = router({
|
||||||
contactPhone: data.contactPhone,
|
contactPhone: data.contactPhone,
|
||||||
startupCreatedDate: data.startupCreatedDate,
|
startupCreatedDate: data.startupCreatedDate,
|
||||||
gdprConsentAt: now.toISOString(),
|
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,
|
userId: user.id,
|
||||||
type: NotificationTypes.APPLICATION_SUBMITTED,
|
type: NotificationTypes.APPLICATION_SUBMITTED,
|
||||||
title: 'Application Received',
|
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}`,
|
linkUrl: `/team/projects/${project.id}`,
|
||||||
linkLabel: 'View Application',
|
linkLabel: 'View Application',
|
||||||
metadata: {
|
metadata: {
|
||||||
projectName: data.projectName,
|
projectName: data.projectName,
|
||||||
programName: round.program.name,
|
programName: program.name,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -340,24 +540,26 @@ export const applicationRouter = router({
|
||||||
projectName: data.projectName,
|
projectName: data.projectName,
|
||||||
applicantName: data.contactName,
|
applicantName: data.contactName,
|
||||||
applicantEmail: data.contactEmail,
|
applicantEmail: data.contactEmail,
|
||||||
programName: round.program.name,
|
programName: program.name,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
projectId: project.id,
|
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
|
checkEmailAvailability: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
mode: z.enum(['edition', 'round']).default('round'),
|
||||||
|
programId: z.string().optional(),
|
||||||
|
roundId: z.string().optional(),
|
||||||
email: z.string().email(),
|
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: {
|
where: {
|
||||||
roundId: input.roundId,
|
roundId: input.roundId,
|
||||||
submittedByEmail: input.email,
|
submittedByEmail: input.email,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: !existing,
|
available: !existing,
|
||||||
message: 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,
|
: null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ export const fileRouter = router({
|
||||||
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
||||||
mimeType: z.string(),
|
mimeType: z.string(),
|
||||||
size: z.number().int().positive(),
|
size: z.number().int().positive(),
|
||||||
|
roundId: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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 bucket = BUCKET_NAME
|
||||||
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
||||||
|
|
||||||
|
|
@ -143,6 +157,8 @@ export const fileRouter = router({
|
||||||
size: input.size,
|
size: input.size,
|
||||||
bucket,
|
bucket,
|
||||||
objectKey,
|
objectKey,
|
||||||
|
roundId: input.roundId,
|
||||||
|
isLate,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -157,6 +173,8 @@ export const fileRouter = router({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
fileType: input.fileType,
|
fileType: input.fileType,
|
||||||
|
roundId: input.roundId,
|
||||||
|
isLate,
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,7 @@ export const notionImportRouter = router({
|
||||||
// Create project
|
// Create project
|
||||||
await ctx.prisma.project.create({
|
await ctx.prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
|
programId: round.programId,
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
|
import { wizardConfigSchema } from '@/types/wizard-config'
|
||||||
|
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||||
|
|
||||||
export const programRouter = router({
|
export const programRouter = router({
|
||||||
/**
|
/**
|
||||||
|
|
@ -93,8 +96,10 @@ export const programRouter = router({
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
slug: z.string().min(1).max(100).optional(),
|
||||||
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
settingsJson: z.record(z.any()).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|
@ -145,4 +150,66 @@ export const programRouter = router({
|
||||||
|
|
||||||
return program
|
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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { metadataJson, ...rest } = 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 project = await ctx.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.project.create({
|
const created = await tx.project.create({
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
|
programId: round.programId,
|
||||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
},
|
},
|
||||||
|
|
@ -557,11 +565,12 @@ export const projectRouter = router({
|
||||||
|
|
||||||
// Create projects in a transaction
|
// Create projects in a transaction
|
||||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
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 projectData = input.projects.map((p) => {
|
||||||
const { metadataJson, ...rest } = p
|
const { metadataJson, ...rest } = p
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
programId: input.programId,
|
||||||
roundId: input.roundId!,
|
roundId: input.roundId!,
|
||||||
status: 'SUBMITTED' as const,
|
status: 'SUBMITTED' as const,
|
||||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
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
|
* Get a single round with stats
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ export const typeformImportRouter = router({
|
||||||
// Create project
|
// Create project
|
||||||
await ctx.prisma.project.create({
|
await ctx.prisma.project.create({
|
||||||
data: {
|
data: {
|
||||||
|
programId: round.programId,
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
title: String(title).trim(),
|
title: String(title).trim(),
|
||||||
|
|
|
||||||
|
|
@ -452,7 +452,7 @@ export const userRouter = router({
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string().optional(),
|
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(),
|
expertiseTags: z.array(z.string()).optional(),
|
||||||
// Optional pre-assignments for jury members
|
// Optional pre-assignments for jury members
|
||||||
assignments: z
|
assignments: z
|
||||||
|
|
@ -468,6 +468,15 @@ export const userRouter = router({
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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)
|
// Deduplicate input by email (keep first occurrence)
|
||||||
const seenEmails = new Set<string>()
|
const seenEmails = new Set<string>()
|
||||||
const uniqueUsers = input.users.filter((u) => {
|
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