From 59436ed67a15a0bb8a3cab7524f68c5248cd2f0c Mon Sep 17 00:00:00 2001
From: Matt
Date: Thu, 5 Feb 2026 23:31:41 +0100
Subject: [PATCH] Implement 15 platform features: digest, availability,
templates, comparison, live voting SSE, file versioning, mentorship,
messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented:
- F1: Email digest notifications with cron endpoint and per-user frequency
- F2: Jury availability windows and workload preferences in smart assignment
- F3: Round templates with save-from-round and CRUD management
- F4: Side-by-side project comparison view for jury members
- F5: Real-time voting dashboard with Server-Sent Events (SSE)
- F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations
- F7: File versioning, inline preview, bulk download with presigned URLs
- F8: Mentor dashboard: milestones, private notes, activity tracking
- F9: Communication hub with broadcasts, templates, and recipient targeting
- F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export
- F11: Applicant draft saving with magic link resume and cron cleanup
- F12: Webhook integration layer with HMAC signing, retry, and delivery logs
- F13: Peer review discussions with anonymized scores and threaded comments
- F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention
- F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher
Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program
New routers: roundTemplate, message, webhook (registered in _app.ts)
New services: email-digest, webhook-dispatcher
New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup
New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download
All features are admin-configurable via SystemSettings or per-model settingsJson fields.
Docker build verified successfully.
Co-Authored-By: Claude Opus 4.6
---
messages/en.json | 321 +++++
messages/fr.json | 321 +++++
next.config.ts | 5 +-
package-lock.json | 1081 +++++++++++------
package.json | 1 +
prisma/schema.prisma | 315 ++++-
src/app/(admin)/admin/audit/page.tsx | 116 ++
src/app/(admin)/admin/messages/page.tsx | 551 +++++++++
.../(admin)/admin/messages/templates/page.tsx | 472 +++++++
.../admin/programs/[id]/mentorship/page.tsx | 433 +++++++
src/app/(admin)/admin/projects/[id]/page.tsx | 2 +
src/app/(admin)/admin/reports/page.tsx | 272 ++++-
.../admin/round-templates/[id]/page.tsx | 443 +++++++
.../(admin)/admin/round-templates/page.tsx | 302 +++++
.../(admin)/admin/rounds/[id]/edit/page.tsx | 320 ++++-
.../admin/rounds/[id]/live-voting/page.tsx | 268 +++-
src/app/(admin)/admin/rounds/[id]/page.tsx | 34 +
src/app/(admin)/admin/rounds/new/page.tsx | 82 +-
.../(admin)/admin/settings/templates/page.tsx | 431 +++++++
.../(admin)/admin/settings/webhooks/page.tsx | 706 +++++++++++
src/app/(jury)/jury/compare/page.tsx | 397 ++++++
src/app/(jury)/jury/live/[sessionId]/page.tsx | 38 +-
.../jury/projects/[id]/discussion/page.tsx | 366 ++++++
src/app/(mentor)/mentor/page.tsx | 71 +-
.../(mentor)/mentor/projects/[id]/page.tsx | 434 ++++++-
src/app/(observer)/observer/reports/page.tsx | 201 ++-
.../(public)/live-scores/[sessionId]/page.tsx | 192 ++-
src/app/(settings)/settings/profile/page.tsx | 102 ++
src/app/api/cron/audit-cleanup/route.ts | 48 +
src/app/api/cron/digest/route.ts | 39 +
src/app/api/cron/draft-cleanup/route.ts | 37 +
src/app/api/files/bulk-download/route.ts | 124 ++
src/app/api/live-voting/stream/route.ts | 180 +++
src/app/layout.tsx | 13 +-
src/components/admin/pdf-report.tsx | 156 +++
.../charts/cross-round-comparison.tsx | 153 +++
src/components/charts/diversity-metrics.tsx | 230 ++++
src/components/charts/index.ts | 4 +
src/components/charts/juror-consistency.tsx | 171 +++
src/components/layouts/admin-sidebar.tsx | 12 +
src/components/layouts/jury-nav.tsx | 7 +-
src/components/settings/settings-content.tsx | 505 +++++++-
src/components/shared/discussion-thread.tsx | 145 +++
src/components/shared/file-preview.tsx | 103 ++
src/components/shared/file-viewer.tsx | 239 +++-
src/components/shared/language-switcher.tsx | 61 +
.../shared/live-score-animation.tsx | 173 +++
src/components/shared/qr-code-display.tsx | 135 ++
src/hooks/use-live-voting-sse.ts | 113 ++
src/i18n/request.ts | 22 +
src/server/routers/_app.ts | 8 +
src/server/routers/analytics.ts | 268 ++++
src/server/routers/application.ts | 276 ++++-
src/server/routers/audit.ts | 157 ++-
src/server/routers/evaluation.ts | 395 +++++-
src/server/routers/export.ts | 232 +++-
src/server/routers/file.ts | 263 ++++
src/server/routers/live-voting.ts | 283 ++++-
src/server/routers/mentor.ts | 498 ++++++++
src/server/routers/message.ts | 406 +++++++
src/server/routers/round.ts | 16 +
src/server/routers/roundTemplate.ts | 209 ++++
src/server/routers/settings.ts | 254 +++-
src/server/routers/user.ts | 43 +-
src/server/routers/webhook.ts | 304 +++++
src/server/services/email-digest.ts | 276 +++++
src/server/services/smart-assignment.ts | 78 +-
src/server/services/webhook-dispatcher.ts | 174 +++
68 files changed, 14541 insertions(+), 546 deletions(-)
create mode 100644 messages/en.json
create mode 100644 messages/fr.json
create mode 100644 src/app/(admin)/admin/messages/page.tsx
create mode 100644 src/app/(admin)/admin/messages/templates/page.tsx
create mode 100644 src/app/(admin)/admin/programs/[id]/mentorship/page.tsx
create mode 100644 src/app/(admin)/admin/round-templates/[id]/page.tsx
create mode 100644 src/app/(admin)/admin/round-templates/page.tsx
create mode 100644 src/app/(admin)/admin/settings/templates/page.tsx
create mode 100644 src/app/(admin)/admin/settings/webhooks/page.tsx
create mode 100644 src/app/(jury)/jury/compare/page.tsx
create mode 100644 src/app/(jury)/jury/projects/[id]/discussion/page.tsx
create mode 100644 src/app/api/cron/audit-cleanup/route.ts
create mode 100644 src/app/api/cron/digest/route.ts
create mode 100644 src/app/api/cron/draft-cleanup/route.ts
create mode 100644 src/app/api/files/bulk-download/route.ts
create mode 100644 src/app/api/live-voting/stream/route.ts
create mode 100644 src/components/admin/pdf-report.tsx
create mode 100644 src/components/charts/cross-round-comparison.tsx
create mode 100644 src/components/charts/diversity-metrics.tsx
create mode 100644 src/components/charts/juror-consistency.tsx
create mode 100644 src/components/shared/discussion-thread.tsx
create mode 100644 src/components/shared/file-preview.tsx
create mode 100644 src/components/shared/language-switcher.tsx
create mode 100644 src/components/shared/live-score-animation.tsx
create mode 100644 src/components/shared/qr-code-display.tsx
create mode 100644 src/hooks/use-live-voting-sse.ts
create mode 100644 src/i18n/request.ts
create mode 100644 src/server/routers/message.ts
create mode 100644 src/server/routers/roundTemplate.ts
create mode 100644 src/server/routers/webhook.ts
create mode 100644 src/server/services/email-digest.ts
create mode 100644 src/server/services/webhook-dispatcher.ts
diff --git a/messages/en.json b/messages/en.json
new file mode 100644
index 0000000..addf53d
--- /dev/null
+++ b/messages/en.json
@@ -0,0 +1,321 @@
+{
+ "common": {
+ "loading": "Loading...",
+ "save": "Save",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "edit": "Edit",
+ "create": "Create",
+ "close": "Close",
+ "confirm": "Confirm",
+ "back": "Back",
+ "next": "Next",
+ "submit": "Submit",
+ "search": "Search",
+ "filter": "Filter",
+ "export": "Export",
+ "import": "Import",
+ "refresh": "Refresh",
+ "actions": "Actions",
+ "status": "Status",
+ "name": "Name",
+ "email": "Email",
+ "role": "Role",
+ "date": "Date",
+ "description": "Description",
+ "settings": "Settings",
+ "yes": "Yes",
+ "no": "No",
+ "all": "All",
+ "none": "None",
+ "noResults": "No results found",
+ "error": "Error",
+ "success": "Success",
+ "warning": "Warning",
+ "info": "Info",
+ "required": "Required",
+ "optional": "Optional",
+ "total": "Total",
+ "page": "Page",
+ "of": "of",
+ "showing": "Showing",
+ "entries": "entries",
+ "perPage": "per page",
+ "language": "Language",
+ "english": "English",
+ "french": "French"
+ },
+ "auth": {
+ "signIn": "Sign In",
+ "signOut": "Sign Out",
+ "signUp": "Sign Up",
+ "email": "Email Address",
+ "password": "Password",
+ "forgotPassword": "Forgot Password?",
+ "resetPassword": "Reset Password",
+ "newPassword": "New Password",
+ "confirmPassword": "Confirm Password",
+ "currentPassword": "Current Password",
+ "magicLink": "Sign in with Magic Link",
+ "magicLinkSent": "Check your email for a sign-in link",
+ "invalidCredentials": "Invalid email or password",
+ "accountLocked": "Account locked. Try again later.",
+ "setPassword": "Set Password",
+ "passwordRequirements": "Password must be at least 8 characters",
+ "passwordsDoNotMatch": "Passwords do not match",
+ "welcomeBack": "Welcome back",
+ "signInDescription": "Enter your email to sign in to your account",
+ "orContinueWith": "Or continue with"
+ },
+ "nav": {
+ "dashboard": "Dashboard",
+ "programs": "Programs",
+ "rounds": "Rounds",
+ "projects": "Projects",
+ "users": "Users",
+ "evaluations": "Evaluations",
+ "assignments": "Assignments",
+ "analytics": "Analytics",
+ "settings": "Settings",
+ "audit": "Audit Log",
+ "myProjects": "My Projects",
+ "myEvaluations": "My Evaluations",
+ "profile": "Profile",
+ "help": "Help",
+ "notifications": "Notifications",
+ "mentoring": "Mentoring",
+ "liveVoting": "Live Voting",
+ "applications": "Applications",
+ "messages": "Messages"
+ },
+ "dashboard": {
+ "title": "Dashboard",
+ "welcome": "Welcome, {name}",
+ "overview": "Overview",
+ "recentActivity": "Recent Activity",
+ "pendingEvaluations": "Pending Evaluations",
+ "completedEvaluations": "Completed Evaluations",
+ "totalProjects": "Total Projects",
+ "activeRounds": "Active Rounds",
+ "assignedProjects": "Assigned Projects",
+ "upcomingDeadlines": "Upcoming Deadlines",
+ "quickActions": "Quick Actions",
+ "noActivity": "No recent activity"
+ },
+ "programs": {
+ "title": "Programs",
+ "createProgram": "Create Program",
+ "editProgram": "Edit Program",
+ "programName": "Program Name",
+ "year": "Year",
+ "status": "Status",
+ "rounds": "Rounds",
+ "projects": "Projects",
+ "noPrograms": "No programs found",
+ "deleteConfirm": "Are you sure you want to delete this program?"
+ },
+ "rounds": {
+ "title": "Rounds",
+ "createRound": "Create Round",
+ "editRound": "Edit Round",
+ "roundName": "Round Name",
+ "roundType": "Round Type",
+ "startDate": "Start Date",
+ "endDate": "End Date",
+ "votingWindow": "Voting Window",
+ "criteria": "Evaluation Criteria",
+ "status": "Status",
+ "active": "Active",
+ "closed": "Closed",
+ "upcoming": "Upcoming",
+ "noRounds": "No rounds found"
+ },
+ "projects": {
+ "title": "Projects",
+ "createProject": "Create Project",
+ "editProject": "Edit Project",
+ "projectName": "Project Title",
+ "teamName": "Team Name",
+ "country": "Country",
+ "category": "Category",
+ "status": "Status",
+ "description": "Description",
+ "files": "Files",
+ "evaluations": "Evaluations",
+ "noProjects": "No projects found",
+ "importCsv": "Import CSV",
+ "bulkStatusUpdate": "Bulk Status Update",
+ "viewDetails": "View Details",
+ "assignMentor": "Assign Mentor",
+ "oceanIssue": "Ocean Issue"
+ },
+ "evaluations": {
+ "title": "Evaluations",
+ "submitEvaluation": "Submit Evaluation",
+ "draft": "Draft",
+ "submitted": "Submitted",
+ "score": "Score",
+ "feedback": "Feedback",
+ "criteria": "Criteria",
+ "globalScore": "Global Score",
+ "decision": "Decision",
+ "recommend": "Recommend",
+ "doNotRecommend": "Do Not Recommend",
+ "saveAsDraft": "Save as Draft",
+ "finalSubmit": "Final Submit",
+ "confirmSubmit": "Are you sure? This action cannot be undone.",
+ "progress": "Progress",
+ "completionRate": "Completion Rate",
+ "noEvaluations": "No evaluations yet",
+ "evaluationSummary": "Evaluation Summary",
+ "strengths": "Strengths",
+ "weaknesses": "Weaknesses",
+ "overallAssessment": "Overall Assessment"
+ },
+ "users": {
+ "title": "Users",
+ "createUser": "Create User",
+ "editUser": "Edit User",
+ "inviteUser": "Invite User",
+ "bulkImport": "Bulk Import",
+ "sendInvitation": "Send Invitation",
+ "resendInvitation": "Resend Invitation",
+ "role": "Role",
+ "status": "Status",
+ "active": "Active",
+ "invited": "Invited",
+ "suspended": "Suspended",
+ "noUsers": "No users found",
+ "expertiseTags": "Expertise Tags",
+ "maxAssignments": "Max Assignments",
+ "lastLogin": "Last Login",
+ "deleteConfirm": "Are you sure you want to delete this user?"
+ },
+ "assignments": {
+ "title": "Assignments",
+ "assign": "Assign",
+ "unassign": "Unassign",
+ "bulkAssign": "Bulk Assign",
+ "smartAssign": "Smart Assignment",
+ "manual": "Manual",
+ "algorithm": "Algorithm",
+ "aiAuto": "AI Auto",
+ "noAssignments": "No assignments",
+ "assignedTo": "Assigned to",
+ "assignedBy": "Assigned by"
+ },
+ "files": {
+ "title": "Files",
+ "upload": "Upload File",
+ "download": "Download",
+ "delete": "Delete File",
+ "fileName": "File Name",
+ "fileType": "File Type",
+ "fileSize": "File Size",
+ "uploadDate": "Upload Date",
+ "noFiles": "No files uploaded",
+ "dragAndDrop": "Drag and drop files here",
+ "maxSize": "Maximum file size: {size}",
+ "version": "Version",
+ "versionHistory": "Version History",
+ "replaceFile": "Replace File",
+ "bulkDownload": "Bulk Download"
+ },
+ "settings": {
+ "title": "Settings",
+ "general": "General",
+ "branding": "Branding",
+ "email": "Email",
+ "security": "Security",
+ "ai": "AI Configuration",
+ "storage": "Storage",
+ "language": "Language",
+ "defaultLanguage": "Default Language",
+ "availableLanguages": "Available Languages",
+ "languageDescription": "Configure the platform's language settings",
+ "saved": "Settings saved successfully",
+ "saveFailed": "Failed to save settings"
+ },
+ "liveVoting": {
+ "title": "Live Voting",
+ "session": "Session",
+ "startVoting": "Start Voting",
+ "stopVoting": "Stop Voting",
+ "endSession": "End Session",
+ "timeRemaining": "Time Remaining",
+ "castVote": "Cast Your Vote",
+ "voteSubmitted": "Vote submitted",
+ "results": "Results",
+ "juryScore": "Jury Score",
+ "audienceScore": "Audience Score",
+ "weightedTotal": "Weighted Total",
+ "noVotes": "No votes yet",
+ "votingClosed": "Voting has closed",
+ "presentationSettings": "Presentation Settings",
+ "audienceVoting": "Audience Voting"
+ },
+ "mentor": {
+ "title": "Mentoring",
+ "myMentees": "My Mentees",
+ "projectDetails": "Project Details",
+ "sendMessage": "Send Message",
+ "notes": "Notes",
+ "addNote": "Add Note",
+ "milestones": "Milestones",
+ "completeMilestone": "Mark Complete",
+ "activity": "Activity",
+ "lastViewed": "Last Viewed",
+ "noMentees": "No mentees assigned"
+ },
+ "profile": {
+ "title": "Profile",
+ "editProfile": "Edit Profile",
+ "name": "Full Name",
+ "email": "Email",
+ "phone": "Phone Number",
+ "country": "Country",
+ "bio": "Bio",
+ "expertise": "Areas of Expertise",
+ "notifications": "Notification Preferences",
+ "digestFrequency": "Digest Frequency",
+ "availability": "Availability",
+ "workload": "Preferred Workload",
+ "changePassword": "Change Password",
+ "deleteAccount": "Delete Account",
+ "deleteAccountConfirm": "This action cannot be undone. All your data will be permanently deleted."
+ },
+ "onboarding": {
+ "welcome": "Welcome to MOPC",
+ "setupProfile": "Let's set up your profile",
+ "step1": "Personal Information",
+ "step2": "Expertise & Preferences",
+ "step3": "Review & Complete",
+ "complete": "Complete Setup",
+ "skip": "Skip for now"
+ },
+ "errors": {
+ "generic": "Something went wrong. Please try again.",
+ "notFound": "Page not found",
+ "unauthorized": "You are not authorized to access this page",
+ "forbidden": "Access denied",
+ "serverError": "Internal server error",
+ "networkError": "Network error. Please check your connection.",
+ "sessionExpired": "Your session has expired. Please sign in again.",
+ "validationError": "Please check the form for errors"
+ },
+ "notifications": {
+ "title": "Notifications",
+ "markAllRead": "Mark all as read",
+ "noNotifications": "No notifications",
+ "viewAll": "View all notifications"
+ },
+ "coi": {
+ "title": "Conflict of Interest",
+ "declaration": "COI Declaration",
+ "declareConflict": "Declare Conflict of Interest",
+ "noConflict": "No conflict of interest",
+ "hasConflict": "Conflict declared",
+ "reason": "Reason for conflict",
+ "confirmDeclaration": "I confirm this declaration is accurate"
+ }
+}
diff --git a/messages/fr.json b/messages/fr.json
new file mode 100644
index 0000000..fd7600e
--- /dev/null
+++ b/messages/fr.json
@@ -0,0 +1,321 @@
+{
+ "common": {
+ "loading": "Chargement...",
+ "save": "Enregistrer",
+ "cancel": "Annuler",
+ "delete": "Supprimer",
+ "edit": "Modifier",
+ "create": "Cr\u00e9er",
+ "close": "Fermer",
+ "confirm": "Confirmer",
+ "back": "Retour",
+ "next": "Suivant",
+ "submit": "Soumettre",
+ "search": "Rechercher",
+ "filter": "Filtrer",
+ "export": "Exporter",
+ "import": "Importer",
+ "refresh": "Actualiser",
+ "actions": "Actions",
+ "status": "Statut",
+ "name": "Nom",
+ "email": "E-mail",
+ "role": "R\u00f4le",
+ "date": "Date",
+ "description": "Description",
+ "settings": "Param\u00e8tres",
+ "yes": "Oui",
+ "no": "Non",
+ "all": "Tous",
+ "none": "Aucun",
+ "noResults": "Aucun r\u00e9sultat trouv\u00e9",
+ "error": "Erreur",
+ "success": "Succ\u00e8s",
+ "warning": "Avertissement",
+ "info": "Information",
+ "required": "Obligatoire",
+ "optional": "Facultatif",
+ "total": "Total",
+ "page": "Page",
+ "of": "de",
+ "showing": "Affichage de",
+ "entries": "entr\u00e9es",
+ "perPage": "par page",
+ "language": "Langue",
+ "english": "Anglais",
+ "french": "Fran\u00e7ais"
+ },
+ "auth": {
+ "signIn": "Se connecter",
+ "signOut": "Se d\u00e9connecter",
+ "signUp": "S'inscrire",
+ "email": "Adresse e-mail",
+ "password": "Mot de passe",
+ "forgotPassword": "Mot de passe oubli\u00e9 ?",
+ "resetPassword": "R\u00e9initialiser le mot de passe",
+ "newPassword": "Nouveau mot de passe",
+ "confirmPassword": "Confirmer le mot de passe",
+ "currentPassword": "Mot de passe actuel",
+ "magicLink": "Se connecter avec un lien magique",
+ "magicLinkSent": "V\u00e9rifiez votre e-mail pour un lien de connexion",
+ "invalidCredentials": "E-mail ou mot de passe invalide",
+ "accountLocked": "Compte verrouill\u00e9. R\u00e9essayez plus tard.",
+ "setPassword": "D\u00e9finir le mot de passe",
+ "passwordRequirements": "Le mot de passe doit contenir au moins 8 caract\u00e8res",
+ "passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
+ "welcomeBack": "Bon retour",
+ "signInDescription": "Entrez votre e-mail pour vous connecter \u00e0 votre compte",
+ "orContinueWith": "Ou continuer avec"
+ },
+ "nav": {
+ "dashboard": "Tableau de bord",
+ "programs": "Programmes",
+ "rounds": "Tours",
+ "projects": "Projets",
+ "users": "Utilisateurs",
+ "evaluations": "\u00c9valuations",
+ "assignments": "Affectations",
+ "analytics": "Analytique",
+ "settings": "Param\u00e8tres",
+ "audit": "Journal d'audit",
+ "myProjects": "Mes projets",
+ "myEvaluations": "Mes \u00e9valuations",
+ "profile": "Profil",
+ "help": "Aide",
+ "notifications": "Notifications",
+ "mentoring": "Mentorat",
+ "liveVoting": "Vote en direct",
+ "applications": "Candidatures",
+ "messages": "Messages"
+ },
+ "dashboard": {
+ "title": "Tableau de bord",
+ "welcome": "Bienvenue, {name}",
+ "overview": "Aper\u00e7u",
+ "recentActivity": "Activit\u00e9 r\u00e9cente",
+ "pendingEvaluations": "\u00c9valuations en attente",
+ "completedEvaluations": "\u00c9valuations termin\u00e9es",
+ "totalProjects": "Total des projets",
+ "activeRounds": "Tours actifs",
+ "assignedProjects": "Projets assign\u00e9s",
+ "upcomingDeadlines": "\u00c9ch\u00e9ances \u00e0 venir",
+ "quickActions": "Actions rapides",
+ "noActivity": "Aucune activit\u00e9 r\u00e9cente"
+ },
+ "programs": {
+ "title": "Programmes",
+ "createProgram": "Cr\u00e9er un programme",
+ "editProgram": "Modifier le programme",
+ "programName": "Nom du programme",
+ "year": "Ann\u00e9e",
+ "status": "Statut",
+ "rounds": "Tours",
+ "projects": "Projets",
+ "noPrograms": "Aucun programme trouv\u00e9",
+ "deleteConfirm": "\u00cates-vous s\u00fbr de vouloir supprimer ce programme ?"
+ },
+ "rounds": {
+ "title": "Tours",
+ "createRound": "Cr\u00e9er un tour",
+ "editRound": "Modifier le tour",
+ "roundName": "Nom du tour",
+ "roundType": "Type de tour",
+ "startDate": "Date de d\u00e9but",
+ "endDate": "Date de fin",
+ "votingWindow": "Fen\u00eatre de vote",
+ "criteria": "Crit\u00e8res d'\u00e9valuation",
+ "status": "Statut",
+ "active": "Actif",
+ "closed": "Cl\u00f4tur\u00e9",
+ "upcoming": "\u00c0 venir",
+ "noRounds": "Aucun tour trouv\u00e9"
+ },
+ "projects": {
+ "title": "Projets",
+ "createProject": "Cr\u00e9er un projet",
+ "editProject": "Modifier le projet",
+ "projectName": "Titre du projet",
+ "teamName": "Nom de l'\u00e9quipe",
+ "country": "Pays",
+ "category": "Cat\u00e9gorie",
+ "status": "Statut",
+ "description": "Description",
+ "files": "Fichiers",
+ "evaluations": "\u00c9valuations",
+ "noProjects": "Aucun projet trouv\u00e9",
+ "importCsv": "Importer CSV",
+ "bulkStatusUpdate": "Mise \u00e0 jour en masse du statut",
+ "viewDetails": "Voir les d\u00e9tails",
+ "assignMentor": "Assigner un mentor",
+ "oceanIssue": "Probl\u00e9matique oc\u00e9anique"
+ },
+ "evaluations": {
+ "title": "\u00c9valuations",
+ "submitEvaluation": "Soumettre l'\u00e9valuation",
+ "draft": "Brouillon",
+ "submitted": "Soumis",
+ "score": "Note",
+ "feedback": "Commentaires",
+ "criteria": "Crit\u00e8res",
+ "globalScore": "Note globale",
+ "decision": "D\u00e9cision",
+ "recommend": "Recommander",
+ "doNotRecommend": "Ne pas recommander",
+ "saveAsDraft": "Enregistrer comme brouillon",
+ "finalSubmit": "Soumission finale",
+ "confirmSubmit": "\u00cates-vous s\u00fbr ? Cette action est irr\u00e9versible.",
+ "progress": "Progression",
+ "completionRate": "Taux de compl\u00e9tion",
+ "noEvaluations": "Aucune \u00e9valuation pour le moment",
+ "evaluationSummary": "R\u00e9sum\u00e9 de l'\u00e9valuation",
+ "strengths": "Points forts",
+ "weaknesses": "Points faibles",
+ "overallAssessment": "\u00c9valuation globale"
+ },
+ "users": {
+ "title": "Utilisateurs",
+ "createUser": "Cr\u00e9er un utilisateur",
+ "editUser": "Modifier l'utilisateur",
+ "inviteUser": "Inviter un utilisateur",
+ "bulkImport": "Importation en masse",
+ "sendInvitation": "Envoyer l'invitation",
+ "resendInvitation": "Renvoyer l'invitation",
+ "role": "R\u00f4le",
+ "status": "Statut",
+ "active": "Actif",
+ "invited": "Invit\u00e9",
+ "suspended": "Suspendu",
+ "noUsers": "Aucun utilisateur trouv\u00e9",
+ "expertiseTags": "Tags d'expertise",
+ "maxAssignments": "Affectations max.",
+ "lastLogin": "Derni\u00e8re connexion",
+ "deleteConfirm": "\u00cates-vous s\u00fbr de vouloir supprimer cet utilisateur ?"
+ },
+ "assignments": {
+ "title": "Affectations",
+ "assign": "Affecter",
+ "unassign": "D\u00e9saffecter",
+ "bulkAssign": "Affectation en masse",
+ "smartAssign": "Affectation intelligente",
+ "manual": "Manuel",
+ "algorithm": "Algorithme",
+ "aiAuto": "IA automatique",
+ "noAssignments": "Aucune affectation",
+ "assignedTo": "Affect\u00e9 \u00e0",
+ "assignedBy": "Affect\u00e9 par"
+ },
+ "files": {
+ "title": "Fichiers",
+ "upload": "T\u00e9l\u00e9charger un fichier",
+ "download": "T\u00e9l\u00e9charger",
+ "delete": "Supprimer le fichier",
+ "fileName": "Nom du fichier",
+ "fileType": "Type de fichier",
+ "fileSize": "Taille du fichier",
+ "uploadDate": "Date de t\u00e9l\u00e9chargement",
+ "noFiles": "Aucun fichier t\u00e9l\u00e9charg\u00e9",
+ "dragAndDrop": "Glissez-d\u00e9posez les fichiers ici",
+ "maxSize": "Taille maximale du fichier : {size}",
+ "version": "Version",
+ "versionHistory": "Historique des versions",
+ "replaceFile": "Remplacer le fichier",
+ "bulkDownload": "T\u00e9l\u00e9chargement en masse"
+ },
+ "settings": {
+ "title": "Param\u00e8tres",
+ "general": "G\u00e9n\u00e9ral",
+ "branding": "Image de marque",
+ "email": "E-mail",
+ "security": "S\u00e9curit\u00e9",
+ "ai": "Configuration IA",
+ "storage": "Stockage",
+ "language": "Langue",
+ "defaultLanguage": "Langue par d\u00e9faut",
+ "availableLanguages": "Langues disponibles",
+ "languageDescription": "Configurer les param\u00e8tres de langue de la plateforme",
+ "saved": "Param\u00e8tres enregistr\u00e9s avec succ\u00e8s",
+ "saveFailed": "\u00c9chec de l'enregistrement des param\u00e8tres"
+ },
+ "liveVoting": {
+ "title": "Vote en direct",
+ "session": "Session",
+ "startVoting": "D\u00e9marrer le vote",
+ "stopVoting": "Arr\u00eater le vote",
+ "endSession": "Terminer la session",
+ "timeRemaining": "Temps restant",
+ "castVote": "Voter",
+ "voteSubmitted": "Vote soumis",
+ "results": "R\u00e9sultats",
+ "juryScore": "Note du jury",
+ "audienceScore": "Note du public",
+ "weightedTotal": "Total pond\u00e9r\u00e9",
+ "noVotes": "Aucun vote pour le moment",
+ "votingClosed": "Le vote est clos",
+ "presentationSettings": "Param\u00e8tres de pr\u00e9sentation",
+ "audienceVoting": "Vote du public"
+ },
+ "mentor": {
+ "title": "Mentorat",
+ "myMentees": "Mes mentees",
+ "projectDetails": "D\u00e9tails du projet",
+ "sendMessage": "Envoyer un message",
+ "notes": "Notes",
+ "addNote": "Ajouter une note",
+ "milestones": "Jalons",
+ "completeMilestone": "Marquer comme termin\u00e9",
+ "activity": "Activit\u00e9",
+ "lastViewed": "Derni\u00e8re consultation",
+ "noMentees": "Aucun mentee assign\u00e9"
+ },
+ "profile": {
+ "title": "Profil",
+ "editProfile": "Modifier le profil",
+ "name": "Nom complet",
+ "email": "E-mail",
+ "phone": "Num\u00e9ro de t\u00e9l\u00e9phone",
+ "country": "Pays",
+ "bio": "Biographie",
+ "expertise": "Domaines d'expertise",
+ "notifications": "Pr\u00e9f\u00e9rences de notification",
+ "digestFrequency": "Fr\u00e9quence du digest",
+ "availability": "Disponibilit\u00e9",
+ "workload": "Charge de travail pr\u00e9f\u00e9r\u00e9e",
+ "changePassword": "Changer le mot de passe",
+ "deleteAccount": "Supprimer le compte",
+ "deleteAccountConfirm": "Cette action est irr\u00e9versible. Toutes vos donn\u00e9es seront d\u00e9finitivement supprim\u00e9es."
+ },
+ "onboarding": {
+ "welcome": "Bienvenue sur MOPC",
+ "setupProfile": "Configurons votre profil",
+ "step1": "Informations personnelles",
+ "step2": "Expertise et pr\u00e9f\u00e9rences",
+ "step3": "V\u00e9rification et finalisation",
+ "complete": "Terminer la configuration",
+ "skip": "Passer pour le moment"
+ },
+ "errors": {
+ "generic": "Une erreur s'est produite. Veuillez r\u00e9essayer.",
+ "notFound": "Page non trouv\u00e9e",
+ "unauthorized": "Vous n'\u00eates pas autoris\u00e9 \u00e0 acc\u00e9der \u00e0 cette page",
+ "forbidden": "Acc\u00e8s refus\u00e9",
+ "serverError": "Erreur interne du serveur",
+ "networkError": "Erreur r\u00e9seau. V\u00e9rifiez votre connexion.",
+ "sessionExpired": "Votre session a expir\u00e9. Veuillez vous reconnecter.",
+ "validationError": "Veuillez v\u00e9rifier le formulaire pour les erreurs"
+ },
+ "notifications": {
+ "title": "Notifications",
+ "markAllRead": "Tout marquer comme lu",
+ "noNotifications": "Aucune notification",
+ "viewAll": "Voir toutes les notifications"
+ },
+ "coi": {
+ "title": "Conflit d'int\u00e9r\u00eats",
+ "declaration": "D\u00e9claration de COI",
+ "declareConflict": "D\u00e9clarer un conflit d'int\u00e9r\u00eats",
+ "noConflict": "Aucun conflit d'int\u00e9r\u00eats",
+ "hasConflict": "Conflit d\u00e9clar\u00e9",
+ "reason": "Raison du conflit",
+ "confirmDeclaration": "Je confirme que cette d\u00e9claration est exacte"
+ }
+}
diff --git a/next.config.ts b/next.config.ts
index 0078638..becc049 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,4 +1,5 @@
import type { NextConfig } from 'next'
+import createNextIntlPlugin from 'next-intl/plugin'
const nextConfig: NextConfig = {
output: 'standalone',
@@ -14,4 +15,6 @@ const nextConfig: NextConfig = {
},
}
-export default nextConfig
+const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
+
+export default withNextIntl(nextConfig)
diff --git a/package-lock.json b/package-lock.json
index 89a9cee..ea1d947 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,7 +43,6 @@
"@trpc/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678",
- "@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -55,6 +54,7 @@
"motion": "^11.15.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
+ "next-intl": "^4.8.2",
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
@@ -69,19 +69,18 @@
"sonner": "^2.0.7",
"superjson": "^2.2.2",
"tailwind-merge": "^3.4.0",
- "twilio": "^5.4.0",
"use-debounce": "^10.0.4",
"zod": "^3.24.1"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@types/bcryptjs": "^2.4.6",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
- "autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"postcss": "^8.4.49",
@@ -1001,6 +1000,67 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
+ "node_modules/@formatjs/ecma402-abstract": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz",
+ "integrity": "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "3.1.0",
+ "@formatjs/intl-localematcher": "0.8.1",
+ "decimal.js": "^10.6.0",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz",
+ "integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "3.1.0",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/fast-memoize": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz",
+ "integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/icu-messageformat-parser": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz",
+ "integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "3.1.1",
+ "@formatjs/icu-skeleton-parser": "2.1.1",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/icu-skeleton-parser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz",
+ "integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "3.1.1",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/intl-localematcher": {
+ "version": "0.5.10",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
+ "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2"
+ }
+ },
"node_modules/@handlewithcare/prosemirror-inputrules": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@handlewithcare/prosemirror-inputrules/-/prosemirror-inputrules-0.1.4.tgz",
@@ -1852,6 +1912,301 @@
"url": "https://github.com/sponsors/panva"
}
},
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
+ "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.3",
+ "is-glob": "^4.0.3",
+ "node-addon-api": "^7.0.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.6",
+ "@parcel/watcher-darwin-arm64": "2.5.6",
+ "@parcel/watcher-darwin-x64": "2.5.6",
+ "@parcel/watcher-freebsd-x64": "2.5.6",
+ "@parcel/watcher-linux-arm-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm-musl": "2.5.6",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.6",
+ "@parcel/watcher-linux-arm64-musl": "2.5.6",
+ "@parcel/watcher-linux-x64-glibc": "2.5.6",
+ "@parcel/watcher-linux-x64-musl": "2.5.6",
+ "@parcel/watcher-win32-arm64": "2.5.6",
+ "@parcel/watcher-win32-ia32": "2.5.6",
+ "@parcel/watcher-win32-x64": "2.5.6"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
+ "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
+ "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
+ "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
+ "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
+ "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
+ "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
+ "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
+ "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
+ "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
+ "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
+ "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
+ "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.6",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
+ "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
@@ -3721,6 +4076,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@schummar/icu-type-parser": {
+ "version": "1.21.5",
+ "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
+ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
+ "license": "MIT"
+ },
"node_modules/@shikijs/types": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.21.0.tgz",
@@ -3749,6 +4110,172 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz",
+ "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz",
+ "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz",
+ "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz",
+ "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz",
+ "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz",
+ "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz",
+ "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz",
+ "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz",
+ "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz",
+ "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -3758,6 +4285,15 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@swc/types": {
+ "version": "0.1.25",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
+ "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -4468,6 +5004,7 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
@@ -4497,6 +5034,7 @@
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
@@ -5280,18 +5818,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/agent-base": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
- "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
- "license": "MIT",
- "dependencies": {
- "debug": "4"
- },
- "engines": {
- "node": ">= 6.0.0"
- }
- },
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -5552,43 +6078,6 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
- "node_modules/autoprefixer": {
- "version": "10.4.23",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
- "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/autoprefixer"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "browserslist": "^4.28.1",
- "caniuse-lite": "^1.0.30001760",
- "fraction.js": "^5.3.4",
- "picocolors": "^1.1.1",
- "postcss-value-parser": "^4.2.0"
- },
- "bin": {
- "autoprefixer": "bin/autoprefixer"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- },
- "peerDependencies": {
- "postcss": "^8.1.0"
- }
- },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -5614,17 +6103,6 @@
"node": ">=4"
}
},
- "node_modules/axios": {
- "version": "1.13.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
- "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.6",
- "form-data": "^4.0.4",
- "proxy-from-env": "^1.1.0"
- }
- },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -5652,16 +6130,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/baseline-browser-mapping": {
- "version": "2.9.19",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
- "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.js"
- }
- },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -5710,40 +6178,6 @@
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
- "node_modules/browserslist": {
- "version": "4.28.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
- },
- "bin": {
- "browserslist": "cli.js"
- },
- "engines": {
- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
- }
- },
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
@@ -5753,12 +6187,6 @@
"node": ">=8.0.0"
}
},
- "node_modules/buffer-equal-constant-time": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
- "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
- "license": "BSD-3-Clause"
- },
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -6319,12 +6747,6 @@
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
- "node_modules/dayjs": {
- "version": "1.11.19",
- "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
- "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
- "license": "MIT"
- },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -6342,6 +6764,12 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "license": "MIT"
+ },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -6522,15 +6950,6 @@
"node": ">= 0.4"
}
},
- "node_modules/ecdsa-sig-formatter": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
- "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "safe-buffer": "^5.0.1"
- }
- },
"node_modules/effect": {
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
@@ -6542,13 +6961,6 @@
"fast-check": "^3.23.1"
}
},
- "node_modules/electron-to-chromium": {
- "version": "1.5.279",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz",
- "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==",
- "dev": true,
- "license": "ISC"
- },
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
@@ -6829,16 +7241,6 @@
"@esbuild/win32-x64": "0.27.2"
}
},
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -7516,26 +7918,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -7567,20 +7949,6 @@
"node": ">= 6"
}
},
- "node_modules/fraction.js": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
- "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "*"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/rawify"
- }
- },
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
@@ -8205,17 +8573,19 @@
"url": "https://opencollective.com/unified"
}
},
- "node_modules/https-proxy-agent": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
- "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "node_modules/icu-minify": {
+ "version": "4.8.2",
+ "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.2.tgz",
+ "integrity": "sha512-LHBQV+skKkjZSPd590pZ7ZAHftUgda3eFjeuNwA8/15L8T8loCNBktKQyTlkodAU86KovFXeg/9WntlAo5wA5A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/amannn"
+ }
+ ],
"license": "MIT",
"dependencies": {
- "agent-base": "6",
- "debug": "4"
- },
- "engines": {
- "node": ">= 6"
+ "@formatjs/icu-messageformat-parser": "^3.4.0"
}
},
"node_modules/ignore": {
@@ -8316,6 +8686,18 @@
"node": ">=12"
}
},
+ "node_modules/intl-messageformat": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz",
+ "integrity": "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "3.1.1",
+ "@formatjs/fast-memoize": "3.1.0",
+ "@formatjs/icu-messageformat-parser": "3.5.1",
+ "tslib": "^2.8.1"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
@@ -8489,7 +8871,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -8534,7 +8915,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -8876,28 +9256,6 @@
"json5": "lib/cli.js"
}
},
- "node_modules/jsonwebtoken": {
- "version": "9.0.3",
- "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
- "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
- "license": "MIT",
- "dependencies": {
- "jws": "^4.0.1",
- "lodash.includes": "^4.3.0",
- "lodash.isboolean": "^3.0.3",
- "lodash.isinteger": "^4.0.4",
- "lodash.isnumber": "^3.0.3",
- "lodash.isplainobject": "^4.0.6",
- "lodash.isstring": "^4.0.1",
- "lodash.once": "^4.0.0",
- "ms": "^2.1.1",
- "semver": "^7.5.4"
- },
- "engines": {
- "node": ">=12",
- "npm": ">=6"
- }
- },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8914,27 +9272,6 @@
"node": ">=4.0"
}
},
- "node_modules/jwa": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
- "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
- "license": "MIT",
- "dependencies": {
- "buffer-equal-constant-time": "^1.0.1",
- "ecdsa-sig-formatter": "1.0.11",
- "safe-buffer": "^5.0.1"
- }
- },
- "node_modules/jws": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
- "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
- "license": "MIT",
- "dependencies": {
- "jwa": "^2.0.1",
- "safe-buffer": "^5.0.1"
- }
- },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9298,54 +9635,12 @@
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
- "node_modules/lodash.includes": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
- "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
- "license": "MIT"
- },
- "node_modules/lodash.isboolean": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
- "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
- "license": "MIT"
- },
- "node_modules/lodash.isinteger": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
- "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
- "license": "MIT"
- },
- "node_modules/lodash.isnumber": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
- "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
- "license": "MIT"
- },
- "node_modules/lodash.isplainobject": {
- "version": "4.0.6",
- "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
- "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
- "license": "MIT"
- },
- "node_modules/lodash.isstring": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
- "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
- "license": "MIT"
- },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
- "node_modules/lodash.once": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
- "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
- "license": "MIT"
- },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -10407,6 +10702,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/next": {
"version": "15.5.10",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.10.tgz",
@@ -10515,6 +10819,93 @@
}
}
},
+ "node_modules/next-intl": {
+ "version": "4.8.2",
+ "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.8.2.tgz",
+ "integrity": "sha512-GuuwyvyEI49/oehQbBXEoY8KSIYCzmfMLhmIwhMXTb+yeBmly1PnJcpgph3KczQ+HTJMXwXCmkizgtT8jBMf3A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/amannn"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/intl-localematcher": "^0.5.4",
+ "@parcel/watcher": "^2.4.1",
+ "@swc/core": "^1.15.2",
+ "icu-minify": "^4.8.2",
+ "negotiator": "^1.0.0",
+ "next-intl-swc-plugin-extractor": "^4.8.2",
+ "po-parser": "^2.1.1",
+ "use-intl": "^4.8.2"
+ },
+ "peerDependencies": {
+ "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-intl-swc-plugin-extractor": {
+ "version": "4.8.2",
+ "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.8.2.tgz",
+ "integrity": "sha512-sHDs36L1VZmFHj3tPHsD+KZJtnsRudHlNvT0ieIe3iFVn5OpGLTxW3d/Zc/2LXSj5GpGuR6wQeikbhFjU9tMQQ==",
+ "license": "MIT"
+ },
+ "node_modules/next-intl/node_modules/@swc/core": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz",
+ "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.25"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.15.11",
+ "@swc/core-darwin-x64": "1.15.11",
+ "@swc/core-linux-arm-gnueabihf": "1.15.11",
+ "@swc/core-linux-arm64-gnu": "1.15.11",
+ "@swc/core-linux-arm64-musl": "1.15.11",
+ "@swc/core-linux-x64-gnu": "1.15.11",
+ "@swc/core-linux-x64-musl": "1.15.11",
+ "@swc/core-win32-arm64-msvc": "1.15.11",
+ "@swc/core-win32-ia32-msvc": "1.15.11",
+ "@swc/core-win32-x64-msvc": "1.15.11"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.17"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-intl/node_modules/@swc/helpers": {
+ "version": "0.5.18",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
+ "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -10543,6 +10934,12 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT"
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -10570,13 +10967,6 @@
"devOptional": true,
"license": "MIT"
},
- "node_modules/node-releases": {
- "version": "2.0.27",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
- "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
@@ -10639,6 +11029,7 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -10954,7 +11345,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -11007,6 +11397,12 @@
"node": ">=18"
}
},
+ "node_modules/po-parser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
+ "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==",
+ "license": "MIT"
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -11044,13 +11440,6 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/postcss-value-parser": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
- "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
@@ -11470,12 +11859,6 @@
"prosemirror-transform": "^1.1.0"
}
},
- "node_modules/proxy-from-env": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "license": "MIT"
- },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -11512,21 +11895,6 @@
],
"license": "MIT"
},
- "node_modules/qs": {
- "version": "6.14.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
- "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "side-channel": "^1.1.0"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
@@ -12294,17 +12662,11 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
- "node_modules/scmp": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
- "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==",
- "deprecated": "Just use Node.js's crypto.timingSafeEqual()",
- "license": "BSD-3-Clause"
- },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -12433,6 +12795,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -12452,6 +12815,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -12468,6 +12832,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -12486,6 +12851,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -13057,24 +13423,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/twilio": {
- "version": "5.12.0",
- "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.12.0.tgz",
- "integrity": "sha512-ZAKnDKcWvJSb90xQS13QB5KQOeMJPzsRPHxZqju8i5ALg3D4hNwAF9bpytVTxTJV99BL4Rn6Un+ZtXjGeMpjvQ==",
- "license": "MIT",
- "dependencies": {
- "axios": "^1.12.0",
- "dayjs": "^1.11.9",
- "https-proxy-agent": "^5.0.0",
- "jsonwebtoken": "^9.0.2",
- "qs": "^6.14.1",
- "scmp": "^2.1.0",
- "xmlbuilder": "^13.0.2"
- },
- "engines": {
- "node": ">=14.0"
- }
- },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -13358,37 +13706,6 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
- "node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "escalade": "^3.2.0",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "update-browserslist-db": "cli.js"
- },
- "peerDependencies": {
- "browserslist": ">= 4.21.0"
- }
- },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -13446,6 +13763,27 @@
"react": "*"
}
},
+ "node_modules/use-intl": {
+ "version": "4.8.2",
+ "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz",
+ "integrity": "sha512-3VNXZgDnPFqhIYosQ9W1Hc6K5q+ZelMfawNbexdwL/dY7BTHbceLUBX5Eeex9lgogxTp0pf1SjHuhYNAjr9H3g==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/amannn"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "^3.1.0",
+ "@schummar/icu-type-parser": "1.21.5",
+ "icu-minify": "^4.8.2",
+ "intl-messageformat": "^11.1.0"
+ },
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
+ }
+ },
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
@@ -13965,15 +14303,6 @@
"node": ">=4.0"
}
},
- "node_modules/xmlbuilder": {
- "version": "13.0.2",
- "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
- "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
- "license": "MIT",
- "engines": {
- "node": ">=6.0"
- }
- },
"node_modules/y-prosemirror": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
diff --git a/package.json b/package.json
index 633a48c..2583a61 100644
--- a/package.json
+++ b/package.json
@@ -67,6 +67,7 @@
"motion": "^11.15.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
+ "next-intl": "^4.8.2",
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index dac9731..2dd106d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -110,6 +110,12 @@ enum SettingCategory {
SECURITY
DEFAULTS
WHATSAPP
+ DIGEST
+ ANALYTICS
+ AUDIT_CONFIG
+ INTEGRATIONS
+ LOCALIZATION
+ COMMUNICATION
}
enum NotificationChannel {
@@ -210,6 +216,13 @@ model User {
notificationPreference NotificationChannel @default(EMAIL)
whatsappOptIn Boolean @default(false)
+ // Digest preferences (F1)
+ digestFrequency String @default("none") // none, daily, weekly
+
+ // Availability & workload (F2)
+ availabilityJson Json? @db.JsonB // Array of { start, end } date ranges
+ preferredWorkload Int? // Preferred number of assignments
+
// Onboarding (Phase 2B)
onboardingCompletedAt DateTime?
@@ -269,6 +282,24 @@ model User {
// Mentor messages
mentorMessages MentorMessage[] @relation("MentorMessageSender")
+ // Digest logs (F1)
+ digestLogs DigestLog[]
+
+ // Mentor notes & milestones (F8)
+ mentorNotesMade MentorNote[] @relation("MentorNoteAuthor")
+ milestoneCompletions MentorMilestoneCompletion[] @relation("MilestoneCompletedByUser")
+
+ // Messages (F9)
+ sentMessages Message[] @relation("MessageSender")
+ messageRecipients MessageRecipient[]
+
+ // Webhooks (F12)
+ createdWebhooks Webhook[] @relation("WebhookCreatedBy")
+
+ // Discussion comments (F13)
+ discussionComments DiscussionComment[]
+ closedDiscussions EvaluationDiscussion[] @relation("DiscussionClosedBy")
+
// NextAuth relations
accounts Account[]
sessions Session[]
@@ -338,6 +369,7 @@ model Program {
partners Partner[]
specialAwards SpecialAward[]
taggingJobs TaggingJob[]
+ mentorMilestones MentorMilestone[]
@@unique([name, year])
@@index([status])
@@ -475,6 +507,11 @@ model Project {
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
logoProvider String? // Storage provider used: 's3' or 'local'
+ // Draft saving (F11)
+ isDraft Boolean @default(false)
+ draftDataJson Json? @db.JsonB
+ draftExpiresAt DateTime?
+
// Flexible fields
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
@@ -498,6 +535,7 @@ model Project {
statusHistory ProjectStatusHistory[]
mentorMessages MentorMessage[]
evaluationSummaries EvaluationSummary[]
+ discussions EvaluationDiscussion[]
@@index([roundId])
@@index([status])
@@ -526,6 +564,10 @@ model ProjectFile {
isLate Boolean @default(false) // Uploaded after round deadline
+ // File versioning (F7)
+ version Int @default(1)
+ replacedById String? // Points to newer version of this file
+
createdAt DateTime @default(now())
// Relations
@@ -675,6 +717,10 @@ model AuditLog {
// Details
detailsJson Json? @db.JsonB // Before/after values, additional context
+ // Audit enhancements (F14)
+ sessionId String? // Groups actions in same user session
+ previousDataJson Json? @db.JsonB // Snapshot of data before change
+
// Request info
ipAddress String?
userAgent String?
@@ -951,6 +997,12 @@ model LiveVotingSession {
votingEndsAt DateTime?
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
+ // Live voting UX enhancements (F5/F6)
+ presentationSettingsJson Json? @db.JsonB // theme, auto-advance, branding
+ allowAudienceVotes Boolean @default(false)
+ audienceVoteWeight Float @default(0) // 0-1 weight relative to jury
+ tieBreakerMethod String @default("admin_decides") // admin_decides, highest_individual, revote
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -962,12 +1014,13 @@ model LiveVotingSession {
}
model LiveVote {
- id String @id @default(cuid())
- sessionId String
- projectId String
- userId String
- score Int // 1-10
- votedAt DateTime @default(now())
+ id String @id @default(cuid())
+ sessionId String
+ projectId String
+ userId String
+ score Int // 1-10
+ isAudienceVote Boolean @default(false) // F6: audience voting
+ votedAt DateTime @default(now())
// Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@ -977,6 +1030,7 @@ model LiveVote {
@@index([sessionId])
@@index([projectId])
@@index([userId])
+ @@index([isAudienceVote])
}
// =============================================================================
@@ -1020,9 +1074,15 @@ model MentorAssignment {
expertiseMatchScore Float?
aiReasoning String? @db.Text
+ // Mentor dashboard enhancements (F8)
+ lastViewedAt DateTime?
+ completionStatus String @default("in_progress") // in_progress, completed, paused
+
// Relations
- project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
- mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
+ notes MentorNote[]
+ milestoneCompletions MentorMilestoneCompletion[]
@@index([mentorId])
@@index([method])
@@ -1427,3 +1487,242 @@ model MentorMessage {
@@index([projectId, createdAt])
}
+
+// =============================================================================
+// DIGEST LOGS (F1: Email Digest)
+// =============================================================================
+
+model DigestLog {
+ id String @id @default(cuid())
+ userId String
+ digestType String // "daily", "weekly"
+ contentJson Json @db.JsonB
+ sentAt DateTime @default(now())
+
+ // Relations
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@index([sentAt])
+}
+
+// =============================================================================
+// ROUND TEMPLATES (F3)
+// =============================================================================
+
+model RoundTemplate {
+ id String @id @default(cuid())
+ name String
+ description String? @db.Text
+ programId String? // null = global template
+ roundType RoundType @default(EVALUATION)
+ criteriaJson Json @db.JsonB
+ settingsJson Json? @db.JsonB
+ assignmentConfig Json? @db.JsonB
+ createdBy String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([programId])
+}
+
+// =============================================================================
+// MENTOR NOTES & MILESTONES (F8)
+// =============================================================================
+
+model MentorNote {
+ id String @id @default(cuid())
+ mentorAssignmentId String
+ authorId String
+ content String @db.Text
+ isVisibleToAdmin Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relations
+ mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
+ author User @relation("MentorNoteAuthor", fields: [authorId], references: [id])
+
+ @@index([mentorAssignmentId])
+}
+
+model MentorMilestone {
+ id String @id @default(cuid())
+ programId String
+ name String
+ description String? @db.Text
+ isRequired Boolean @default(false)
+ deadlineOffsetDays Int?
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relations
+ program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
+ completions MentorMilestoneCompletion[]
+
+ @@index([programId])
+ @@index([sortOrder])
+}
+
+model MentorMilestoneCompletion {
+ id String @id @default(cuid())
+ milestoneId String
+ mentorAssignmentId String
+ completedAt DateTime @default(now())
+ completedById String
+
+ // Relations
+ milestone MentorMilestone @relation(fields: [milestoneId], references: [id], onDelete: Cascade)
+ mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
+ completedBy User @relation("MilestoneCompletedByUser", fields: [completedById], references: [id])
+
+ @@unique([milestoneId, mentorAssignmentId])
+ @@index([mentorAssignmentId])
+}
+
+// =============================================================================
+// COMMUNICATION HUB (F9)
+// =============================================================================
+
+model Message {
+ id String @id @default(cuid())
+ senderId String
+ recipientType String // USER, ROLE, ROUND_JURY, PROGRAM_TEAM, ALL
+ recipientFilter Json? @db.JsonB
+ roundId String?
+ templateId String?
+ subject String
+ body String @db.Text
+ deliveryChannels String[]
+ scheduledAt DateTime?
+ sentAt DateTime?
+ metadata Json? @db.JsonB
+ createdAt DateTime @default(now())
+
+ // Relations
+ sender User @relation("MessageSender", fields: [senderId], references: [id])
+ template MessageTemplate? @relation(fields: [templateId], references: [id])
+ recipients MessageRecipient[]
+
+ @@index([senderId])
+ @@index([sentAt])
+ @@index([scheduledAt])
+}
+
+model MessageTemplate {
+ id String @id @default(cuid())
+ name String
+ category String
+ subject String
+ body String @db.Text
+ variables Json? @db.JsonB
+ isActive Boolean @default(true)
+ createdBy String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relations
+ messages Message[]
+
+ @@index([category])
+ @@index([isActive])
+}
+
+model MessageRecipient {
+ id String @id @default(cuid())
+ messageId String
+ userId String
+ channel String // EMAIL, IN_APP, WHATSAPP
+ isRead Boolean @default(false)
+ readAt DateTime?
+ deliveredAt DateTime?
+
+ // Relations
+ message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([messageId])
+ @@index([userId, isRead])
+}
+
+// =============================================================================
+// WEBHOOKS (F12)
+// =============================================================================
+
+model Webhook {
+ id String @id @default(cuid())
+ name String
+ url String
+ secret String // HMAC signing key
+ events String[]
+ headers Json? @db.JsonB
+ isActive Boolean @default(true)
+ maxRetries Int @default(3)
+ createdById String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ // Relations
+ createdBy User @relation("WebhookCreatedBy", fields: [createdById], references: [id])
+ deliveries WebhookDelivery[]
+
+ @@index([isActive])
+}
+
+model WebhookDelivery {
+ id String @id @default(cuid())
+ webhookId String
+ event String
+ payload Json @db.JsonB
+ responseStatus Int?
+ responseBody String? @db.Text
+ attempts Int @default(0)
+ lastAttemptAt DateTime?
+ status String @default("PENDING") // PENDING, DELIVERED, FAILED
+ createdAt DateTime @default(now())
+
+ // Relations
+ webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
+
+ @@index([webhookId])
+ @@index([status])
+ @@index([createdAt])
+}
+
+// =============================================================================
+// PEER REVIEW / EVALUATION DISCUSSIONS (F13)
+// =============================================================================
+
+model EvaluationDiscussion {
+ id String @id @default(cuid())
+ projectId String
+ roundId String
+ status String @default("open") // open, closed
+ createdAt DateTime @default(now())
+ closedAt DateTime?
+ closedById String?
+
+ // Relations
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ closedBy User? @relation("DiscussionClosedBy", fields: [closedById], references: [id])
+ comments DiscussionComment[]
+
+ @@unique([projectId, roundId])
+ @@index([roundId])
+ @@index([status])
+}
+
+model DiscussionComment {
+ id String @id @default(cuid())
+ discussionId String
+ userId String
+ content String @db.Text
+ createdAt DateTime @default(now())
+
+ // Relations
+ discussion EvaluationDiscussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id])
+
+ @@index([discussionId, createdAt])
+}
diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx
index 747791b..4c1b5b7 100644
--- a/src/app/(admin)/admin/audit/page.tsx
+++ b/src/app/(admin)/admin/audit/page.tsx
@@ -48,7 +48,11 @@ import {
ChevronRight,
RefreshCw,
RotateCcw,
+ AlertTriangle,
+ Layers,
+ ArrowLeftRight,
} from 'lucide-react'
+import { Switch } from '@/components/ui/switch'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
@@ -127,6 +131,7 @@ export default function AuditLogPage() {
const [page, setPage] = useState(1)
const [expandedRows, setExpandedRows] = useState>(new Set())
const [showFilters, setShowFilters] = useState(true)
+ const [groupBySession, setGroupBySession] = useState(false)
// Build query input
const queryInput = useMemo(
@@ -153,6 +158,11 @@ export default function AuditLogPage() {
perPage: 100,
})
+ // Fetch anomalies
+ const { data: anomalyData } = trpc.audit.getAnomalies.useQuery({}, {
+ retry: false,
+ })
+
// Export mutation
const exportLogs = trpc.export.auditLogs.useQuery(
{
@@ -384,6 +394,54 @@ export default function AuditLogPage() {
+ {/* Anomaly Alerts */}
+ {anomalyData && anomalyData.anomalies.length > 0 && (
+
+
+
+
+ Anomaly Alerts ({anomalyData.anomalies.length})
+
+
+
+
+ {anomalyData.anomalies.slice(0, 5).map((anomaly, i) => (
+
+
+
+
{anomaly.isRapid ? 'Rapid Activity' : 'Bulk Operations'}
+
{String(anomaly.actionCount)} actions in {String(anomaly.timeWindowMinutes)} min ({anomaly.actionsPerMinute.toFixed(1)}/min)
+ {anomaly.userId && (
+
+ User: {String(anomaly.user?.name || anomaly.userId)}
+
+ )}
+
+
+ {String(anomaly.actionCount)} actions
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Session Grouping Toggle */}
+
+
+
+
+
+
+
{/* Results */}
{isLoading ? (
@@ -485,6 +543,28 @@ export default function AuditLogPage() {
)}
+ {!!(log as Record).previousDataJson && (
+
+
+
+ Changes (Before / After)
+
+
).previousDataJson}
+ after={log.detailsJson}
+ />
+
+ )}
+ {groupBySession && !!(log as Record).sessionId && (
+
+
+ Session ID
+
+
+ {String((log as Record).sessionId)}
+
+
+ )}
@@ -625,6 +705,42 @@ export default function AuditLogPage() {
)
}
+function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
+ const beforeObj = typeof before === 'object' && before !== null ? before as Record : {}
+ const afterObj = typeof after === 'object' && after !== null ? after as Record : {}
+ const allKeys = Array.from(new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)]))
+ const changedKeys = allKeys.filter(
+ (key) => JSON.stringify(beforeObj[key]) !== JSON.stringify(afterObj[key])
+ )
+
+ if (changedKeys.length === 0) {
+ return (
+ No differences detected
+ )
+ }
+
+ return (
+
+
+ Field
+ Before
+ After
+
+ {changedKeys.map((key) => (
+
+ {key}
+
+ {beforeObj[key] !== undefined ? JSON.stringify(beforeObj[key]) : '--'}
+
+
+ {afterObj[key] !== undefined ? JSON.stringify(afterObj[key]) : '--'}
+
+
+ ))}
+
+ )
+}
+
function AuditLogSkeleton() {
return (
diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx
new file mode 100644
index 0000000..a44a67a
--- /dev/null
+++ b/src/app/(admin)/admin/messages/page.tsx
@@ -0,0 +1,551 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { trpc } from '@/lib/trpc/client'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Switch } from '@/components/ui/switch'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from '@/components/ui/tabs'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Send,
+ Mail,
+ Bell,
+ Clock,
+ Loader2,
+ LayoutTemplate,
+ AlertCircle,
+ Inbox,
+ CheckCircle2,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { formatDate } from '@/lib/utils'
+
+type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
+
+const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
+ { value: 'ALL', label: 'All Users' },
+ { value: 'ROLE', label: 'By Role' },
+ { value: 'ROUND_JURY', label: 'Round Jury' },
+ { value: 'PROGRAM_TEAM', label: 'Program Team' },
+ { value: 'USER', label: 'Specific User' },
+]
+
+const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN']
+
+export default function MessagesPage() {
+ const [recipientType, setRecipientType] = useState('ALL')
+ const [selectedRole, setSelectedRole] = useState('')
+ const [roundId, setRoundId] = useState('')
+ const [selectedProgramId, setSelectedProgramId] = useState('')
+ const [selectedUserId, setSelectedUserId] = useState('')
+ const [subject, setSubject] = useState('')
+ const [body, setBody] = useState('')
+ const [selectedTemplateId, setSelectedTemplateId] = useState('')
+ const [deliveryChannels, setDeliveryChannels] = useState(['EMAIL', 'IN_APP'])
+ const [isScheduled, setIsScheduled] = useState(false)
+ const [scheduledAt, setScheduledAt] = useState('')
+
+ const utils = trpc.useUtils()
+
+ // Fetch supporting data
+ const { data: rounds } = trpc.round.listAll.useQuery()
+ const { data: programs } = trpc.program.list.useQuery()
+ const { data: templates } = trpc.message.listTemplates.useQuery()
+ const { data: users } = trpc.user.list.useQuery(
+ { page: 1, perPage: 100 },
+ { enabled: recipientType === 'USER' }
+ )
+
+ // Fetch sent messages for history
+ const { data: sentMessages, isLoading: loadingSent } = trpc.message.inbox.useQuery(
+ { page: 1, pageSize: 50 }
+ )
+
+ const sendMutation = trpc.message.send.useMutation({
+ onSuccess: (data) => {
+ const count = (data as Record)?.recipientCount || ''
+ toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
+ resetForm()
+ utils.message.inbox.invalidate()
+ },
+ onError: (e) => toast.error(e.message),
+ })
+
+ const resetForm = () => {
+ setSubject('')
+ setBody('')
+ setSelectedTemplateId('')
+ setSelectedRole('')
+ setRoundId('')
+ setSelectedProgramId('')
+ setSelectedUserId('')
+ setIsScheduled(false)
+ setScheduledAt('')
+ }
+
+ const handleTemplateSelect = (templateId: string) => {
+ setSelectedTemplateId(templateId)
+ if (templateId && templateId !== '__none__' && templates) {
+ const template = (templates as Array>).find(
+ (t) => String(t.id) === templateId
+ )
+ if (template) {
+ setSubject(String(template.subject || ''))
+ setBody(String(template.body || ''))
+ }
+ }
+ }
+
+ const toggleChannel = (channel: string) => {
+ setDeliveryChannels((prev) =>
+ prev.includes(channel)
+ ? prev.filter((c) => c !== channel)
+ : [...prev, channel]
+ )
+ }
+
+ const buildRecipientFilter = (): unknown => {
+ switch (recipientType) {
+ case 'ROLE':
+ return selectedRole ? { role: selectedRole } : undefined
+ case 'USER':
+ return selectedUserId ? { userId: selectedUserId } : undefined
+ case 'PROGRAM_TEAM':
+ return selectedProgramId ? { programId: selectedProgramId } : undefined
+ default:
+ return undefined
+ }
+ }
+
+ const handleSend = () => {
+ if (!subject.trim()) {
+ toast.error('Subject is required')
+ return
+ }
+ if (!body.trim()) {
+ toast.error('Message body is required')
+ return
+ }
+ if (deliveryChannels.length === 0) {
+ toast.error('Select at least one delivery channel')
+ return
+ }
+ if (recipientType === 'ROLE' && !selectedRole) {
+ toast.error('Please select a role')
+ return
+ }
+ if (recipientType === 'ROUND_JURY' && !roundId) {
+ toast.error('Please select a round')
+ return
+ }
+ if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
+ toast.error('Please select a program')
+ return
+ }
+ if (recipientType === 'USER' && !selectedUserId) {
+ toast.error('Please select a user')
+ return
+ }
+
+ sendMutation.mutate({
+ recipientType,
+ recipientFilter: buildRecipientFilter(),
+ roundId: roundId || undefined,
+ subject: subject.trim(),
+ body: body.trim(),
+ deliveryChannels,
+ scheduledAt: isScheduled && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
+ templateId: selectedTemplateId && selectedTemplateId !== '__none__' ? selectedTemplateId : undefined,
+ })
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Communication Hub
+
+ Send messages and notifications to platform users
+
+
+
+
+
+
+
+
+
+ Compose
+
+
+
+ Sent History
+
+
+
+
+ {/* Compose Form */}
+
+
+ Compose Message
+
+ Send a message via email, in-app notifications, or both
+
+
+
+ {/* Recipient type */}
+
+
+
+
+
+ {/* Conditional sub-filters */}
+ {recipientType === 'ROLE' && (
+
+
+
+
+ )}
+
+ {recipientType === 'ROUND_JURY' && (
+
+
+
+
+ )}
+
+ {recipientType === 'PROGRAM_TEAM' && (
+
+
+
+
+ )}
+
+ {recipientType === 'USER' && (
+
+
+
+
+ )}
+
+ {recipientType === 'ALL' && (
+
+
+
+ This message will be sent to all platform users.
+
+
+ )}
+
+ {/* Template selector */}
+ {templates && (templates as unknown[]).length > 0 && (
+
+
+
+
+ )}
+
+ {/* Subject */}
+
+
+ setSubject(e.target.value)}
+ />
+
+
+ {/* Body */}
+
+
+
+
+ Variables: {'{{projectName}}'}, {'{{userName}}'}, {'{{deadline}}'}, {'{{roundName}}'}, {'{{programName}}'}
+
+
+
+
+ {/* Delivery channels */}
+
+
+
+
+ toggleChannel('EMAIL')}
+ />
+
+
+
+ toggleChannel('IN_APP')}
+ />
+
+
+
+
+
+ {/* Schedule */}
+
+
+
+
+
+ {isScheduled && (
+
setScheduledAt(e.target.value)}
+ />
+ )}
+
+
+ {/* Send button */}
+
+
+
+
+
+
+
+
+
+
+ Sent Messages
+
+ Recent messages sent through the platform
+
+
+
+ {loadingSent ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ ) : sentMessages && sentMessages.items.length > 0 ? (
+
+
+
+ Subject
+ From
+ Channel
+ Status
+ Date
+
+
+
+ {sentMessages.items.map((item: Record) => {
+ const msg = item.message as Record | undefined
+ const sender = msg?.sender as Record | undefined
+ const channel = String(item.channel || 'EMAIL')
+ const isRead = !!item.isRead
+
+ return (
+
+
+
+ {!isRead && (
+
+ )}
+
+ {String(msg?.subject || 'No subject')}
+
+
+
+
+ {String(sender?.name || sender?.email || 'System')}
+
+
+
+ {channel === 'EMAIL' ? (
+ <>Email>
+ ) : (
+ <>In-App>
+ )}
+
+
+
+ {isRead ? (
+
+
+ Read
+
+ ) : (
+ New
+ )}
+
+
+ {msg?.createdAt
+ ? formatDate(msg.createdAt as string | Date)
+ : ''}
+
+
+ )
+ })}
+
+
+ ) : (
+
+
+
No messages yet
+
+ Sent messages will appear here.
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/app/(admin)/admin/messages/templates/page.tsx b/src/app/(admin)/admin/messages/templates/page.tsx
new file mode 100644
index 0000000..a2cacc0
--- /dev/null
+++ b/src/app/(admin)/admin/messages/templates/page.tsx
@@ -0,0 +1,472 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { trpc } from '@/lib/trpc/client'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Switch } from '@/components/ui/switch'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import {
+ ArrowLeft,
+ Plus,
+ Pencil,
+ Trash2,
+ Loader2,
+ LayoutTemplate,
+ Eye,
+ Variable,
+} from 'lucide-react'
+import { toast } from 'sonner'
+
+const AVAILABLE_VARIABLES = [
+ { name: '{{projectName}}', desc: 'Project title' },
+ { name: '{{userName}}', desc: "Recipient's name" },
+ { name: '{{deadline}}', desc: 'Deadline date' },
+ { name: '{{roundName}}', desc: 'Round name' },
+ { name: '{{programName}}', desc: 'Program name' },
+]
+
+interface TemplateFormData {
+ name: string
+ category: string
+ subject: string
+ body: string
+ variables: string[]
+ isActive: boolean
+}
+
+const defaultForm: TemplateFormData = {
+ name: '',
+ category: '',
+ subject: '',
+ body: '',
+ variables: [],
+ isActive: true,
+}
+
+export default function MessageTemplatesPage() {
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [editingId, setEditingId] = useState(null)
+ const [deleteId, setDeleteId] = useState(null)
+ const [formData, setFormData] = useState(defaultForm)
+ const [showPreview, setShowPreview] = useState(false)
+
+ const utils = trpc.useUtils()
+
+ const { data: templates, isLoading } = trpc.message.listTemplates.useQuery()
+
+ const createMutation = trpc.message.createTemplate.useMutation({
+ onSuccess: () => {
+ utils.message.listTemplates.invalidate()
+ toast.success('Template created')
+ closeDialog()
+ },
+ onError: (e) => toast.error(e.message),
+ })
+
+ const updateMutation = trpc.message.updateTemplate.useMutation({
+ onSuccess: () => {
+ utils.message.listTemplates.invalidate()
+ toast.success('Template updated')
+ closeDialog()
+ },
+ onError: (e) => toast.error(e.message),
+ })
+
+ const deleteMutation = trpc.message.deleteTemplate.useMutation({
+ onSuccess: () => {
+ utils.message.listTemplates.invalidate()
+ toast.success('Template deleted')
+ setDeleteId(null)
+ },
+ onError: (e) => toast.error(e.message),
+ })
+
+ const closeDialog = () => {
+ setDialogOpen(false)
+ setEditingId(null)
+ setFormData(defaultForm)
+ setShowPreview(false)
+ }
+
+ const openEdit = (template: Record) => {
+ setEditingId(String(template.id))
+ setFormData({
+ name: String(template.name || ''),
+ category: String(template.category || ''),
+ subject: String(template.subject || ''),
+ body: String(template.body || ''),
+ variables: Array.isArray(template.variables) ? template.variables.map(String) : [],
+ isActive: template.isActive !== false,
+ })
+ setDialogOpen(true)
+ }
+
+ const insertVariable = (variable: string) => {
+ setFormData((prev) => ({
+ ...prev,
+ body: prev.body + variable,
+ }))
+ }
+
+ const handleSubmit = () => {
+ if (!formData.name.trim() || !formData.subject.trim()) {
+ toast.error('Name and subject are required')
+ return
+ }
+
+ const payload = {
+ name: formData.name.trim(),
+ category: formData.category.trim() || 'General',
+ subject: formData.subject.trim(),
+ body: formData.body.trim(),
+ variables: formData.variables.length > 0 ? formData.variables : undefined,
+ }
+
+ if (editingId) {
+ updateMutation.mutate({ id: editingId, ...payload, isActive: formData.isActive })
+ } else {
+ createMutation.mutate(payload)
+ }
+ }
+
+ const getPreviewText = (text: string): string => {
+ return text
+ .replace(/\{\{userName\}\}/g, 'John Doe')
+ .replace(/\{\{projectName\}\}/g, 'Ocean Cleanup Initiative')
+ .replace(/\{\{roundName\}\}/g, 'Round 1 - Semi-Finals')
+ .replace(/\{\{programName\}\}/g, 'MOPC 2026')
+ .replace(/\{\{deadline\}\}/g, 'March 15, 2026')
+ }
+
+ const isPending = createMutation.isPending || updateMutation.isPending
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Message Templates
+
+ Create and manage reusable message templates
+
+
+
+
+
+ {/* Variable reference panel */}
+
+
+
+
+ Available Template Variables
+
+
+
+
+ {AVAILABLE_VARIABLES.map((v) => (
+
+
+ {v.name}
+
+ {v.desc}
+
+ ))}
+
+
+
+
+ {/* Templates list */}
+ {isLoading ? (
+
+ ) : templates && (templates as unknown[]).length > 0 ? (
+
+
+
+
+ Name
+ Category
+ Subject
+ Status
+ Actions
+
+
+
+ {(templates as Array>).map((template) => (
+
+
+ {String(template.name)}
+
+
+ {template.category ? (
+
+ {String(template.category)}
+
+ ) : (
+ --
+ )}
+
+
+ {String(template.subject || '')}
+
+
+ {template.isActive !== false ? (
+ Active
+ ) : (
+ Inactive
+ )}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+
+
+ No templates yet
+
+ Create a template to speed up message composition.
+
+
+
+ )}
+
+ {/* Delete confirmation */}
+
setDeleteId(null)}>
+
+
+ Delete Template
+
+ Are you sure you want to delete this template? This action cannot be undone.
+
+
+
+ Cancel
+ deleteId && deleteMutation.mutate({ id: deleteId })}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {deleteMutation.isPending && }
+ Delete
+
+
+
+
+
+ )
+}
+
+function TemplatesSkeleton() {
+ return (
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/(admin)/admin/programs/[id]/mentorship/page.tsx b/src/app/(admin)/admin/programs/[id]/mentorship/page.tsx
new file mode 100644
index 0000000..fc6051c
--- /dev/null
+++ b/src/app/(admin)/admin/programs/[id]/mentorship/page.tsx
@@ -0,0 +1,433 @@
+'use client'
+
+import { useState } from 'react'
+import { useParams } from 'next/navigation'
+import Link from 'next/link'
+import { trpc } from '@/lib/trpc/client'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import {
+ ArrowLeft,
+ Plus,
+ Pencil,
+ Trash2,
+ Loader2,
+ ChevronUp,
+ ChevronDown,
+ Target,
+ Calendar,
+} from 'lucide-react'
+import { toast } from 'sonner'
+
+interface MilestoneFormData {
+ name: string
+ description: string
+ isRequired: boolean
+ deadlineOffsetDays: number
+ sortOrder: number
+}
+
+const defaultMilestoneForm: MilestoneFormData = {
+ name: '',
+ description: '',
+ isRequired: false,
+ deadlineOffsetDays: 30,
+ sortOrder: 0,
+}
+
+export default function MentorshipMilestonesPage() {
+ const params = useParams()
+ const programId = params.id as string
+
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [editingId, setEditingId] = useState(null)
+ const [deleteId, setDeleteId] = useState(null)
+ const [formData, setFormData] = useState(defaultMilestoneForm)
+
+ const utils = trpc.useUtils()
+
+ const { data: milestones, isLoading } = trpc.mentor.getMilestones.useQuery({ programId })
+
+ const createMutation = trpc.mentor.createMilestone.useMutation({
+ onSuccess: () => {
+ utils.mentor.getMilestones.invalidate({ programId })
+ toast.success('Milestone created')
+ closeDialog()
+ },
+ onError: (e) => toast.error(e.message),
+ })
+
+ const updateMutation = trpc.mentor.updateMilestone.useMutation({
+ onSuccess: () => {
+ utils.mentor.getMilestones.invalidate({ programId })
+ toast.success('Milestone updated')
+ closeDialog()
+ },
+ onError: (e) => toast.error(e.message),
+ })
+
+ const deleteMutation = trpc.mentor.deleteMilestone.useMutation({
+ onSuccess: () => {
+ utils.mentor.getMilestones.invalidate({ programId })
+ toast.success('Milestone deleted')
+ setDeleteId(null)
+ },
+ onError: (e) => toast.error(e.message),
+ })
+
+ const reorderMutation = trpc.mentor.reorderMilestones.useMutation({
+ onSuccess: () => utils.mentor.getMilestones.invalidate({ programId }),
+ onError: (e) => toast.error(e.message),
+ })
+
+ const closeDialog = () => {
+ setDialogOpen(false)
+ setEditingId(null)
+ setFormData(defaultMilestoneForm)
+ }
+
+ const openEdit = (milestone: Record) => {
+ setEditingId(String(milestone.id))
+ setFormData({
+ name: String(milestone.name || ''),
+ description: String(milestone.description || ''),
+ isRequired: Boolean(milestone.isRequired),
+ deadlineOffsetDays: Number(milestone.deadlineOffsetDays || 30),
+ sortOrder: Number(milestone.sortOrder || 0),
+ })
+ setDialogOpen(true)
+ }
+
+ const openCreate = () => {
+ const nextOrder = milestones ? (milestones as unknown[]).length : 0
+ setFormData({ ...defaultMilestoneForm, sortOrder: nextOrder })
+ setEditingId(null)
+ setDialogOpen(true)
+ }
+
+ const handleSubmit = () => {
+ if (!formData.name.trim()) {
+ toast.error('Milestone name is required')
+ return
+ }
+
+ if (editingId) {
+ updateMutation.mutate({
+ milestoneId: editingId,
+ name: formData.name.trim(),
+ description: formData.description.trim() || undefined,
+ isRequired: formData.isRequired,
+ deadlineOffsetDays: formData.deadlineOffsetDays,
+ sortOrder: formData.sortOrder,
+ })
+ } else {
+ createMutation.mutate({
+ programId,
+ name: formData.name.trim(),
+ description: formData.description.trim() || undefined,
+ isRequired: formData.isRequired,
+ deadlineOffsetDays: formData.deadlineOffsetDays,
+ sortOrder: formData.sortOrder,
+ })
+ }
+ }
+
+ const moveMilestone = (id: string, direction: 'up' | 'down') => {
+ if (!milestones) return
+ const list = milestones as Array>
+ const index = list.findIndex((m) => String(m.id) === id)
+ if (index === -1) return
+ if (direction === 'up' && index === 0) return
+ if (direction === 'down' && index === list.length - 1) return
+
+ const ids = list.map((m) => String(m.id))
+ const [moved] = ids.splice(index, 1)
+ ids.splice(direction === 'up' ? index - 1 : index + 1, 0, moved)
+
+ reorderMutation.mutate({ milestoneIds: ids })
+ }
+
+ const isPending = createMutation.isPending || updateMutation.isPending
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Mentorship Milestones
+
+
+ Configure milestones for the mentorship program
+
+
+
+
+
+ {/* Milestones list */}
+ {isLoading ? (
+
+ ) : milestones && (milestones as unknown[]).length > 0 ? (
+
+ {(milestones as Array
>).map((milestone, index) => {
+ const completions = milestone.completions as Array | undefined
+ const completionCount = completions ? completions.length : 0
+
+ return (
+
+
+
+ {/* Order number and reorder buttons */}
+
+
+
+ {index + 1}
+
+
+
+
+ {/* Content */}
+
+
+ {String(milestone.name)}
+ {!!milestone.isRequired && (
+ Required
+ )}
+
+
+ Day {String(milestone.deadlineOffsetDays || 30)}
+
+ {completionCount > 0 && (
+
+ {completionCount} completions
+
+ )}
+
+ {!!milestone.description && (
+
+ {String(milestone.description)}
+
+ )}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+ )
+ })}
+
+ ) : (
+
+
+
+ No milestones defined
+
+ Add milestones to track mentor-mentee progress.
+
+
+
+ )}
+
+ {/* Delete confirmation */}
+
setDeleteId(null)}>
+
+
+ Delete Milestone
+
+ Are you sure you want to delete this milestone? Progress data associated
+ with it may be lost.
+
+
+
+ Cancel
+
+ deleteId && deleteMutation.mutate({ milestoneId: deleteId })
+ }
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {deleteMutation.isPending && (
+
+ )}
+ Delete
+
+
+
+
+
+ )
+}
+
+function MilestonesSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx
index b5fc479..ad8cfe4 100644
--- a/src/app/(admin)/admin/projects/[id]/page.tsx
+++ b/src/app/(admin)/admin/projects/[id]/page.tsx
@@ -494,6 +494,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{files && files.length > 0 ? (
({
id: f.id,
fileName: f.fileName,
@@ -502,6 +503,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
size: f.size,
bucket: f.bucket,
objectKey: f.objectKey,
+ version: f.version,
}))}
/>
) : (
diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx
index b23a8eb..65dfc6d 100644
--- a/src/app/(admin)/admin/reports/page.tsx
+++ b/src/app/(admin)/admin/reports/page.tsx
@@ -39,6 +39,10 @@ import {
CheckCircle2,
PieChart,
TrendingUp,
+ GitCompare,
+ UserCheck,
+ Globe,
+ Printer,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
@@ -49,6 +53,9 @@ import {
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
+ CrossRoundComparisonChart,
+ JurorConsistencyChart,
+ DiversityMetricsChart,
} from '@/components/charts'
function ReportsOverview() {
@@ -414,6 +421,215 @@ function RoundAnalytics() {
)
}
+function CrossRoundTab() {
+ const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
+
+ const rounds = programs?.flatMap(p =>
+ p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
+ ) || []
+
+ const [selectedRoundIds, setSelectedRoundIds] = useState([])
+
+ const { data: comparison, isLoading: comparisonLoading } =
+ trpc.analytics.getCrossRoundComparison.useQuery(
+ { roundIds: selectedRoundIds },
+ { enabled: selectedRoundIds.length >= 2 }
+ )
+
+ const toggleRound = (roundId: string) => {
+ setSelectedRoundIds((prev) =>
+ prev.includes(roundId)
+ ? prev.filter((id) => id !== roundId)
+ : [...prev, roundId]
+ )
+ }
+
+ if (programsLoading) {
+ return
+ }
+
+ return (
+
+ {/* Round selector */}
+
+
+ Select Rounds to Compare
+
+ Choose at least 2 rounds to compare metrics side by side
+
+
+
+
+ {rounds.map((round) => {
+ const isSelected = selectedRoundIds.includes(round.id)
+ return (
+ toggleRound(round.id)}
+ >
+ {round.programName} - {round.name}
+
+ )
+ })}
+
+ {selectedRoundIds.length < 2 && (
+
+ Select at least 2 rounds to enable comparison
+
+ )}
+
+
+
+ {/* Comparison charts */}
+ {comparisonLoading && selectedRoundIds.length >= 2 && (
+
+ )}
+
+ {comparison && (
+
} />
+ )}
+
+ )
+}
+
+function JurorConsistencyTab() {
+ const [selectedRoundId, setSelectedRoundId] = useState(null)
+
+ const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
+
+ const rounds = programs?.flatMap(p =>
+ p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
+ ) || []
+
+ if (rounds.length && !selectedRoundId) {
+ setSelectedRoundId(rounds[0].id)
+ }
+
+ const { data: consistency, isLoading: consistencyLoading } =
+ trpc.analytics.getJurorConsistency.useQuery(
+ { roundId: selectedRoundId! },
+ { enabled: !!selectedRoundId }
+ )
+
+ if (programsLoading) {
+ return
+ }
+
+ return (
+
+
+
+
+
+
+ {consistencyLoading &&
}
+
+ {consistency && (
+
+ }}
+ />
+ )}
+
+ )
+}
+
+function DiversityTab() {
+ const [selectedRoundId, setSelectedRoundId] = useState(null)
+
+ const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
+
+ const rounds = programs?.flatMap(p =>
+ p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
+ ) || []
+
+ if (rounds.length && !selectedRoundId) {
+ setSelectedRoundId(rounds[0].id)
+ }
+
+ const { data: diversity, isLoading: diversityLoading } =
+ trpc.analytics.getDiversityMetrics.useQuery(
+ { roundId: selectedRoundId! },
+ { enabled: !!selectedRoundId }
+ )
+
+ if (programsLoading) {
+ return
+ }
+
+ return (
+
+
+
+
+
+
+ {diversityLoading &&
}
+
+ {diversity && (
+
+ )}
+
+ )
+}
+
export default function ReportsPage() {
return (
@@ -427,16 +643,40 @@ export default function ReportsPage() {
{/* Tabs */}
-
-
-
- Overview
-
-
-
- Analytics
-
-
+
+
+
+
+ Overview
+
+
+
+ Analytics
+
+
+
+ Cross-Round
+
+
+
+ Juror Consistency
+
+
+
+ Diversity
+
+
+
+
@@ -445,6 +685,18 @@ export default function ReportsPage() {
+
+
+
+
+
+
+
+
+
+
+
+
)
diff --git a/src/app/(admin)/admin/round-templates/[id]/page.tsx b/src/app/(admin)/admin/round-templates/[id]/page.tsx
new file mode 100644
index 0000000..6794946
--- /dev/null
+++ b/src/app/(admin)/admin/round-templates/[id]/page.tsx
@@ -0,0 +1,443 @@
+'use client'
+
+import { use, useState } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { trpc } from '@/lib/trpc/client'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+import { Skeleton } from '@/components/ui/skeleton'
+import { Separator } from '@/components/ui/separator'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ ArrowLeft,
+ Loader2,
+ Save,
+ Trash2,
+ LayoutTemplate,
+ Plus,
+ X,
+ GripVertical,
+} from 'lucide-react'
+import { toast } from 'sonner'
+
+const ROUND_TYPE_LABELS: Record = {
+ FILTERING: 'Filtering',
+ EVALUATION: 'Evaluation',
+ LIVE_EVENT: 'Live Event',
+}
+
+const CRITERION_TYPES = [
+ { value: 'numeric', label: 'Numeric (1-10)' },
+ { value: 'text', label: 'Text' },
+ { value: 'boolean', label: 'Yes/No' },
+ { value: 'section_header', label: 'Section Header' },
+]
+
+type Criterion = {
+ id: string
+ label: string
+ type: string
+ description?: string
+ weight?: number
+ min?: number
+ max?: number
+}
+
+export default function RoundTemplateDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>
+}) {
+ const { id } = use(params)
+ const router = useRouter()
+ const utils = trpc.useUtils()
+
+ const { data: template, isLoading } = trpc.roundTemplate.getById.useQuery({ id })
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+ const [roundType, setRoundType] = useState('EVALUATION')
+ const [criteria, setCriteria] = useState([])
+ const [initialized, setInitialized] = useState(false)
+ const [deleteOpen, setDeleteOpen] = useState(false)
+
+ // Initialize form state from loaded data
+ if (template && !initialized) {
+ setName(template.name)
+ setDescription(template.description || '')
+ setRoundType(template.roundType)
+ setCriteria((template.criteriaJson as Criterion[]) || [])
+ setInitialized(true)
+ }
+
+ const updateTemplate = trpc.roundTemplate.update.useMutation({
+ onSuccess: () => {
+ utils.roundTemplate.getById.invalidate({ id })
+ utils.roundTemplate.list.invalidate()
+ toast.success('Template saved')
+ },
+ onError: (err) => {
+ toast.error(err.message)
+ },
+ })
+
+ const deleteTemplate = trpc.roundTemplate.delete.useMutation({
+ onSuccess: () => {
+ utils.roundTemplate.list.invalidate()
+ router.push('/admin/round-templates')
+ toast.success('Template deleted')
+ },
+ onError: (err) => {
+ toast.error(err.message)
+ },
+ })
+
+ const handleSave = () => {
+ updateTemplate.mutate({
+ id,
+ name: name.trim(),
+ description: description.trim() || undefined,
+ roundType: roundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
+ criteriaJson: criteria,
+ })
+ }
+
+ const addCriterion = () => {
+ setCriteria([
+ ...criteria,
+ {
+ id: `criterion_${Date.now()}`,
+ label: '',
+ type: 'numeric',
+ weight: 1,
+ min: 1,
+ max: 10,
+ },
+ ])
+ }
+
+ const updateCriterion = (index: number, updates: Partial) => {
+ setCriteria(criteria.map((c, i) => (i === index ? { ...c, ...updates } : c)))
+ }
+
+ const removeCriterion = (index: number) => {
+ setCriteria(criteria.filter((_, i) => i !== index))
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (!template) {
+ return (
+
+
+
+
+ Template not found
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ {template.name}
+
+
+ Edit template configuration and criteria
+
+
+
+
+
+
+
+
+
+ {/* Basic Info */}
+
+
+ Basic Information
+
+
+
+
+ setName(e.target.value)}
+ placeholder="e.g., Standard Evaluation Round"
+ />
+
+
+
+
+
+
+
+
+
+ Created {new Date(template.createdAt).toLocaleDateString()} | Last updated{' '}
+ {new Date(template.updatedAt).toLocaleDateString()}
+
+
+
+
+ {/* Criteria */}
+
+
+
+
+ Evaluation Criteria
+
+ Define the criteria jurors will use to evaluate projects
+
+
+
+
+
+
+ {criteria.length === 0 ? (
+
+
No criteria defined yet.
+
+
+ ) : (
+
+ {criteria.map((criterion, index) => (
+
+ {index > 0 &&
}
+
+
+
+
+
+
+
+
+ updateCriterion(index, { label: e.target.value })
+ }
+ placeholder="e.g., Innovation"
+ />
+
+
+
+
+
+ {criterion.type === 'numeric' && (
+
+
+
+ updateCriterion(index, {
+ weight: parseFloat(e.target.value) || 1,
+ })
+ }
+ />
+
+ )}
+
+
+
+ updateCriterion(index, { description: e.target.value })
+ }
+ placeholder="Help text for jurors..."
+ />
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Template Metadata */}
+
+
+ Template Info
+
+
+
+
+
Type
+
+ {ROUND_TYPE_LABELS[template.roundType] || template.roundType}
+
+
+
+
Criteria Count
+
{criteria.length}
+
+
+
Has Custom Settings
+
+ {template.settingsJson && Object.keys(template.settingsJson as object).length > 0
+ ? 'Yes'
+ : 'No'}
+
+
+
+
+
+
+ {/* Delete Dialog */}
+
+
+ )
+}
diff --git a/src/app/(admin)/admin/round-templates/page.tsx b/src/app/(admin)/admin/round-templates/page.tsx
new file mode 100644
index 0000000..2e698bb
--- /dev/null
+++ b/src/app/(admin)/admin/round-templates/page.tsx
@@ -0,0 +1,302 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { trpc } from '@/lib/trpc/client'
+import { Button } from '@/components/ui/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ LayoutTemplate,
+ Plus,
+ Calendar,
+ Settings2,
+ Trash2,
+ Loader2,
+} from 'lucide-react'
+import { toast } from 'sonner'
+
+const ROUND_TYPE_LABELS: Record = {
+ FILTERING: 'Filtering',
+ EVALUATION: 'Evaluation',
+ LIVE_EVENT: 'Live Event',
+}
+
+const ROUND_TYPE_COLORS: Record = {
+ FILTERING: 'secondary',
+ EVALUATION: 'default',
+ LIVE_EVENT: 'outline',
+}
+
+export default function RoundTemplatesPage() {
+ const utils = trpc.useUtils()
+ const { data: templates, isLoading } = trpc.roundTemplate.list.useQuery()
+ const [createOpen, setCreateOpen] = useState(false)
+ const [newName, setNewName] = useState('')
+ const [newDescription, setNewDescription] = useState('')
+ const [newRoundType, setNewRoundType] = useState('EVALUATION')
+ const [deleteId, setDeleteId] = useState(null)
+
+ const createTemplate = trpc.roundTemplate.create.useMutation({
+ onSuccess: () => {
+ utils.roundTemplate.list.invalidate()
+ setCreateOpen(false)
+ setNewName('')
+ setNewDescription('')
+ setNewRoundType('EVALUATION')
+ toast.success('Template created')
+ },
+ onError: (err) => {
+ toast.error(err.message)
+ },
+ })
+
+ const deleteTemplate = trpc.roundTemplate.delete.useMutation({
+ onSuccess: () => {
+ utils.roundTemplate.list.invalidate()
+ setDeleteId(null)
+ toast.success('Template deleted')
+ },
+ onError: (err) => {
+ toast.error(err.message)
+ },
+ })
+
+ const handleCreate = () => {
+ if (!newName.trim()) return
+ createTemplate.mutate({
+ name: newName.trim(),
+ description: newDescription.trim() || undefined,
+ roundType: newRoundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
+ criteriaJson: [],
+ })
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Round Templates
+
+
+ Save and reuse round configurations across editions
+
+
+
+
+
+ {/* Templates Grid */}
+ {templates && templates.length > 0 ? (
+
+ {templates.map((template) => {
+ const criteria = (template.criteriaJson as Array
) || []
+ const hasSettings = template.settingsJson && Object.keys(template.settingsJson as object).length > 0
+
+ return (
+
+
+
+
+
+
+ {template.name}
+
+
+ {ROUND_TYPE_LABELS[template.roundType] || template.roundType}
+
+
+ {template.description && (
+
+ {template.description}
+
+ )}
+
+
+
+
+
+ {criteria.length} criteria
+
+ {hasSettings && (
+
+ Custom settings
+
+ )}
+
+
+ {new Date(template.createdAt).toLocaleDateString()}
+
+
+
+
+ {/* Delete button */}
+
+
+ )
+ })}
+
+ ) : (
+
+
+
+ No templates yet
+
+ Create a template or save an existing round configuration as a template
+
+
+
+
+ )}
+
+ {/* Delete Confirmation Dialog */}
+
+
+ )
+}
diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
index 297048a..77b6468 100644
--- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx
@@ -32,7 +32,10 @@ import {
type Criterion,
} from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
-import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell } from 'lucide-react'
+import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar } from 'lucide-react'
+import { Switch } from '@/components/ui/switch'
+import { Slider } from '@/components/ui/slider'
+import { Label } from '@/components/ui/label'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import {
Select,
@@ -458,6 +461,321 @@ function EditRoundContent({ roundId }: { roundId: string }) {
+ {/* Jury Features */}
+
+
+
+
+ Jury Features
+
+
+ Configure project comparison and peer review for jury members
+
+
+
+ {/* Comparison settings */}
+
+
+
+
+
+ Allow jury members to compare projects side by side
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ enable_comparison: checked,
+ }))
+ }
+ />
+
+ {!!roundSettings.enable_comparison && (
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ comparison_max_projects: parseInt(e.target.value) || 3,
+ }))
+ }
+ className="max-w-[120px]"
+ />
+
+ )}
+
+
+ {/* Peer review settings */}
+
+
+
+
+
+ Allow jury members to discuss and see aggregated scores
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ peer_review_enabled: checked,
+ }))
+ }
+ />
+
+ {!!roundSettings.peer_review_enabled && (
+
+
+
+
+ Score divergence level that triggers a warning (0.0 - 1.0)
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ divergence_threshold: parseFloat(e.target.value) || 0.3,
+ }))
+ }
+ className="max-w-[120px]"
+ />
+
+
+
+
+
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ discussion_window_hours: parseInt(e.target.value) || 48,
+ }))
+ }
+ className="max-w-[120px]"
+ />
+
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ max_comment_length: parseInt(e.target.value) || 2000,
+ }))
+ }
+ className="max-w-[120px]"
+ />
+
+
+ )}
+
+
+
+
+ {/* File Settings */}
+
+
+
+
+ File Settings
+
+
+ Configure allowed file types and versioning for this round
+
+
+
+
+
+
+ Comma-separated MIME types or extensions
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ allowed_file_types: e.target.value,
+ }))
+ }
+ />
+
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ max_file_size_mb: parseInt(e.target.value) || 500,
+ }))
+ }
+ className="max-w-[150px]"
+ />
+
+
+
+
+
+ Keep previous versions when files are replaced
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ file_versioning: checked,
+ }))
+ }
+ />
+
+ {!!roundSettings.file_versioning && (
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ max_file_versions: parseInt(e.target.value) || 5,
+ }))
+ }
+ className="max-w-[120px]"
+ />
+
+ )}
+
+
+
+ {/* Availability Settings */}
+
+
+
+
+ Jury Availability Settings
+
+
+ Configure how jury member availability affects assignments
+
+
+
+
+
+
+
+ Jury members must set availability before receiving assignments
+
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ require_availability: checked,
+ }))
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ How much weight to give availability when using soft penalty mode
+
+
+ setRoundSettings((prev) => ({
+ ...prev,
+ availability_weight: value,
+ }))
+ }
+ className="max-w-xs"
+ />
+
+
+
+
{/* Evaluation Criteria */}
diff --git a/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx b/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx
index aced4d8..09d818a 100644
--- a/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Suspense, use, useState, useEffect } from 'react'
+import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -15,6 +15,15 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
import { toast } from 'sonner'
import {
ArrowLeft,
@@ -28,6 +37,10 @@ import {
AlertCircle,
ExternalLink,
RefreshCw,
+ QrCode,
+ Settings2,
+ Scale,
+ UserCheck,
} from 'lucide-react'
import {
DndContext,
@@ -46,6 +59,8 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
+import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
+import { QRCodeDisplay } from '@/components/shared/qr-code-display'
interface PageProps {
params: Promise<{ id: string }>
@@ -119,11 +134,38 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
const [projectOrder, setProjectOrder] = useState([])
const [countdown, setCountdown] = useState(null)
const [votingDuration, setVotingDuration] = useState(30)
+ const [liveVoteCount, setLiveVoteCount] = useState(null)
+ const [liveAvgScore, setLiveAvgScore] = useState(null)
- // Fetch session data
+ // Fetch session data - reduced polling since SSE handles real-time
const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery(
{ roundId },
- { refetchInterval: 2000 } // Poll every 2 seconds
+ { refetchInterval: 5000 }
+ )
+
+ // SSE for real-time vote updates
+ const onVoteUpdate = useCallback((data: VoteUpdate) => {
+ setLiveVoteCount(data.totalVotes)
+ setLiveAvgScore(data.averageScore)
+ }, [])
+
+ const onSessionStatus = useCallback(() => {
+ refetch()
+ }, [refetch])
+
+ const onProjectChange = useCallback(() => {
+ setLiveVoteCount(null)
+ setLiveAvgScore(null)
+ refetch()
+ }, [refetch])
+
+ const { isConnected } = useLiveVotingSSE(
+ sessionData?.id || null,
+ {
+ onVoteUpdate,
+ onSessionStatus,
+ onProjectChange,
+ }
)
// Mutations
@@ -166,6 +208,26 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
},
})
+ const updateSessionConfig = trpc.liveVoting.updateSessionConfig.useMutation({
+ onSuccess: () => {
+ toast.success('Session config updated')
+ refetch()
+ },
+ onError: (error) => {
+ toast.error(error.message)
+ },
+ })
+
+ const updatePresentationSettings = trpc.liveVoting.updatePresentationSettings.useMutation({
+ onSuccess: () => {
+ toast.success('Presentation settings updated')
+ refetch()
+ },
+ onError: (error) => {
+ toast.error(error.message)
+ },
+ })
+
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
@@ -446,61 +508,185 @@ function LiveVotingContent({ roundId }: { roundId: string }) {
Current Votes
+ {isConnected && (
+
+ )}
- {sessionData.currentVotes.length === 0 ? (
-
- No votes yet
-
- ) : (
-
-
-
Total votes
-
- {sessionData.currentVotes.length}
-
+ {(() => {
+ const voteCount = liveVoteCount ?? sessionData.currentVotes.length
+ const avgScore = liveAvgScore ?? (
+ sessionData.currentVotes.length > 0
+ ? sessionData.currentVotes.reduce((sum, v) => sum + v.score, 0) / sessionData.currentVotes.length
+ : null
+ )
+ if (voteCount === 0) {
+ return (
+
+ No votes yet
+
+ )
+ }
+ return (
+
+
+ Total votes
+ {voteCount}
+
+
+ Average score
+
+ {avgScore !== null ? avgScore.toFixed(1) : '--'}
+
+
-
-
Average score
-
- {(
- sessionData.currentVotes.reduce(
- (sum, v) => sum + v.score,
- 0
- ) / sessionData.currentVotes.length
- ).toFixed(1)}
+ )
+ })()}
+
+
+
+ {/* Session Configuration */}
+
+
+
+
+ Session Config
+
+
+
+
+
+ {
+ updateSessionConfig.mutate({
+ sessionId: sessionData.id,
+ allowAudienceVotes: checked,
+ })
+ }}
+ disabled={isCompleted}
+ />
+
+
+ {sessionData.allowAudienceVotes && (
+
+
+
+ {
+ updateSessionConfig.mutate({
+ sessionId: sessionData.id,
+ audienceVoteWeight: parseInt(e.target.value) / 100,
+ })
+ }}
+ className="flex-1"
+ disabled={isCompleted}
+ />
+
+ {Math.round((sessionData.audienceVoteWeight || 0) * 100)}%
)}
+
+
+
+
+
+
+
+
+
+
- {/* Links */}
+ {/* QR Codes & Links */}
- Voting Links
+
+
+ Voting Links
+
Share these links with participants
-
-
-
+
+
+
+
+
+
+
diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx
index 4061699..d61afe1 100644
--- a/src/app/(admin)/admin/rounds/[id]/page.tsx
+++ b/src/app/(admin)/admin/rounds/[id]/page.tsx
@@ -51,6 +51,7 @@ import {
ListChecks,
ClipboardCheck,
Sparkles,
+ LayoutTemplate,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
@@ -126,6 +127,21 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
+ // Save as template
+ const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
+ onSuccess: (data) => {
+ toast.success('Saved as template', {
+ action: {
+ label: 'View',
+ onClick: () => router.push(`/admin/round-templates/${data.id}`),
+ },
+ })
+ },
+ onError: (err) => {
+ toast.error(err.message)
+ },
+ })
+
// AI summary bulk generation
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
onSuccess: (data) => {
@@ -794,6 +810,24 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
+
diff --git a/src/app/(admin)/admin/rounds/new/page.tsx b/src/app/(admin)/admin/rounds/new/page.tsx
index 9447ced..78d09ff 100644
--- a/src/app/(admin)/admin/rounds/new/page.tsx
+++ b/src/app/(admin)/admin/rounds/new/page.tsx
@@ -34,7 +34,8 @@ import {
FormMessage,
} from '@/components/ui/form'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
-import { ArrowLeft, Loader2, AlertCircle, Bell } from 'lucide-react'
+import { ArrowLeft, Loader2, AlertCircle, Bell, LayoutTemplate } from 'lucide-react'
+import { toast } from 'sonner'
import { DateTimePicker } from '@/components/ui/datetime-picker'
// Available notification types for teams entering a round
@@ -71,9 +72,38 @@ function CreateRoundContent() {
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState>({})
const [entryNotificationType, setEntryNotificationType] = useState('')
+ const [selectedTemplateId, setSelectedTemplateId] = useState('')
const utils = trpc.useUtils()
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
+ const { data: templates } = trpc.roundTemplate.list.useQuery()
+
+ const loadTemplate = (templateId: string) => {
+ if (!templateId || !templates) return
+ const template = templates.find((t) => t.id === templateId)
+ if (!template) return
+
+ // Apply template settings
+ const typeMap: Record = {
+ EVALUATION: 'EVALUATION',
+ SELECTION: 'EVALUATION',
+ FINAL: 'EVALUATION',
+ LIVE_VOTING: 'LIVE_EVENT',
+ FILTERING: 'FILTERING',
+ }
+ setRoundType(typeMap[template.roundType] || 'EVALUATION')
+
+ if (template.settingsJson && typeof template.settingsJson === 'object') {
+ setRoundSettings(template.settingsJson as Record)
+ }
+
+ if (template.name) {
+ form.setValue('name', template.name)
+ }
+
+ setSelectedTemplateId(templateId)
+ toast.success(`Loaded template: ${template.name}`)
+ }
const createRound = trpc.round.create.useMutation({
onSuccess: (data) => {
@@ -155,6 +185,56 @@ function CreateRoundContent() {
+ {/* Template Selector */}
+ {templates && templates.length > 0 && (
+
+
+
+
+ Start from Template
+
+
+ Load settings from a saved template to get started quickly
+
+
+
+
+
+ {selectedTemplateId && (
+
+ )}
+
+
+
+ )}
+
{/* Form */}