diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..f734588 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "garuda-hacks-6-0" + } +} diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml deleted file mode 100644 index ed74736..0000000 --- a/.github/workflows/nextjs.yml +++ /dev/null @@ -1,93 +0,0 @@ -# Sample workflow for building and deploying a Next.js site to GitHub Pages -# -# To get started with Next.js see: https://nextjs.org/docs/getting-started -# -name: Deploy Next.js site to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Build job - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Detect package manager - id: detect-package-manager - run: | - if [ -f "${{ github.workspace }}/yarn.lock" ]; then - echo "manager=yarn" >> $GITHUB_OUTPUT - echo "command=install" >> $GITHUB_OUTPUT - echo "runner=yarn" >> $GITHUB_OUTPUT - exit 0 - elif [ -f "${{ github.workspace }}/package.json" ]; then - echo "manager=npm" >> $GITHUB_OUTPUT - echo "command=ci" >> $GITHUB_OUTPUT - echo "runner=npx --no-install" >> $GITHUB_OUTPUT - exit 0 - else - echo "Unable to determine package manager" - exit 1 - fi - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: ${{ steps.detect-package-manager.outputs.manager }} - - name: Setup Pages - uses: actions/configure-pages@v5 - with: - # Automatically inject basePath in your Next.js configuration file and disable - # server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized). - # - # You may remove this line if you want to manage the configuration yourself. - static_site_generator: next - - name: Restore cache - uses: actions/cache@v4 - with: - path: | - .next/cache - # Generate a new cache whenever packages or source files change. - key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} - # If source files changed but packages didn't, rebuild from a prior cache. - restore-keys: | - ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- - - name: Install dependencies - run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} - - name: Build with Next.js - run: ${{ steps.detect-package-manager.outputs.runner }} next build - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./out - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5ef6a52..388da46 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# for dev only - Ryan +/app/api/create-mentors/** +*adminsdk*.* diff --git a/README.md b/README.md index e215bc4..4f4fbac 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,138 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Garuda Hacks Admin Portal πŸš€ -## Getting Started +The official admin portal for Garuda Hacks 6.0, a premier hackathon event. Built with modern web technologies to provide a seamless experience for administrators. -First, run the development server: +## πŸ› οΈ Tech Stack + +- **Frontend** + + - Next.js 15 + - React 19 + - TypeScript + - TailwindCSS + - Vercel Analytics + +- **Backend** + + - Firebase Cloud Functions + - Firebase Authentication + - Firebase Firestore + +- **Deployment** + - Vercel + - Vercel Analytics + +## πŸš€ Getting Started + +### Prerequisites + +- Node.js (v18 or higher) +- npm or yarn +- Firebase account + +### Installation + +1. Clone the repository + +```bash +git clone https://github.com/your-username/gh-admin.git +cd gh-admin +``` + +2. Install dependencies + +```bash +npm install +``` + +3. Set up environment variables + +```bash +cp .env.example .env +``` + +Fill in your Firebase configuration in `.env` + +4. Start development server ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +### Building for Production + +```bash +npm run build +``` + +## πŸ“ Project Structure + +``` +gh-admin/ +β”œβ”€β”€ app/ # Next.js app directory +β”œβ”€β”€ components/ # Reusable UI components +β”œβ”€β”€ contexts/ # React context providers +β”œβ”€β”€ lib/ # Utility functions and Firebase config +β”œβ”€β”€ public/ # Static assets +β”œβ”€β”€ static/ # Additional static files +β”œβ”€β”€ .github/ # GitHub workflows and templates +β”œβ”€β”€ firebase.json # Firebase configuration +β”œβ”€β”€ next.config.ts # Next.js configuration +└── tailwind.config.ts # Tailwind CSS configuration +``` + +## πŸ”§ Configuration + +### Environment Variables + +Required environment variables: + +- `NEXT_PUBLIC_FIREBASE_API_KEY` +- `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN` +- `NEXT_PUBLIC_FIREBASE_PROJECT_ID` +- `NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET` +- `NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID` +- `NEXT_PUBLIC_FIREBASE_APP_ID` +- `NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID` + +## 🀝 Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feat/amazing-feature`) +3. Make your changes following our commit conventions: + + ```bash + # Format + (): -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + # Examples + feat(auth): add Google OAuth login + fix(api): resolve proxy configuration + docs(readme): update installation steps + style(ui): improve button hover states + refactor(forms): simplify validation logic + test(api): add auth endpoint tests + chore(deps): update dependencies + ``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + Scope: optional, indicates the module affected -## Learn More +4. Push to the branch (`git push origin feat/amazing-feature`) +5. Open a Pull Request -To learn more about Next.js, take a look at the following resources: +## πŸ“ License -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## πŸ™ Acknowledgments -## Deploy on Vercel +- [Next.js](https://nextjs.org/) +- [React](https://reactjs.org/) +- [Firebase](https://firebase.google.com/) +- [TailwindCSS](https://tailwindcss.com/) +- [Vercel](https://vercel.com/) -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +--- -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Made with ❀️ by the Garuda Hacks Team diff --git a/app/api/send-email/route.ts b/app/api/send-email/route.ts new file mode 100644 index 0000000..3343e19 --- /dev/null +++ b/app/api/send-email/route.ts @@ -0,0 +1,496 @@ +import { NextResponse } from "next/server"; +import nodemailer from "nodemailer"; + +interface MailOptions { + from: string | { name: string; address: string }; + to: string; + subject: string; + html: string; + text: string; +} + +const createAcceptanceMailOptions = (email: string): MailOptions => ({ + from: { + name: "Garuda Hacks", + address: "no-reply@garudahacks.com", + }, + to: email, + subject: "Congratulations! You're Accepted to Garuda Hacks 6.0!", + html: ` + + + + + + Congratulations! You're Accepted to Garuda Hacks 6.0! + + + + + + + + +
+ + + + + + + + + + +
+

+ Garuda Hacks 6.0 +

+

+ 24 - 26 July 2025 +

+
+ +
+

+ πŸŽ‰ Congratulations! +

+

+ On behalf of the entire Garuda Hacks team, we're excited to welcome you to Garuda Hacks 6.0, happening from 24 to 26 July 2025 at Universitas Multimedia Nusantara (UMN). We're so glad to have you on board. +

+

+ This year's event is proudly presented in collaboration with Himpunan Mahasiswa Informatika Universitas Multimedia Nusantara (HMIF UMN), who have graciously offered their beautiful campus and full support to ensure a smooth and enjoyable experience for all participants.

+

+ This year is an exciting time to be joining Garuda Hacks. When we began planning for this year's event, we collected feedback from previous years and decided to focus on the participant experience. From a career fair and a networking lunch, to a live judging round with VCs and a revamped application portal, we are determined to make this year our most engaging event yet. We hope that you will enjoy the experience. +

+

+ At the same time, we understand the world is going through challenging times. In moments of uncertainty, it is often the smallest communities that feel the greatest impact. We hope this event will be a space for you to build, connect, and be reminded of our shared mission as creators: to drive meaningful impact within our communities. We hope that this motivation will empower you to consistently think about what you can do to serve people and drive positive and lasting change for them. + +

+
+ + +
+

+ πŸ“… Event Details +

+ + + + + + + + + + + + + +
Date:July 24 - 26, 2025
Location:Universitas Multimedia Nusantara (UMN)
Duration:30 Hours Hacking Period and Finalist Demo Day
+
+ + +
+

+ πŸš€ Action Items +

+
    +
  • RSVP on Portal to confirm your participation at portal.garudahacks.com
  • +
  • If you're under 18, sign and upload the Underage Consent Form on the RSVP Portal
  • +
  • + Join our Discord community for updates and networking: + discord.gg/vQw3UeYzFb +
  • +
  • Follow @garudahacks on Instagram for latest updates
  • +
  • Add the official Garuda Hacks 6.0 Twibbon to your social media: twibbo.nz/garudahacks6
  • +
  • Prepare your development environment and tools
  • +
  • Attend our technical meeting and speed dating sessions (details on Discord & Instagram)
  • +
  • Mark your calendar and get ready to hack!
  • +
+
+ +
+

+ Once again, on behalf of the entire Garuda Hacks committee, sponsors, and partners, WELCOME! We cannot wait to see what you will create! +

+

+ Maria Gracia Athalia & Dominic Kartadjoemena
+ Co-Managing Directors, Garuda Hacks 6.0 +

+
+ + +
+ + + + + +
+ + RSVP + + + + View Handbook + +
+
+ + +
+

+ Questions? Contact us at hiba@garudahacks.com +

+

+ Follow us on social media for the latest updates +

+
+
+ + + + + + +
+

+ Β© 2025 Garuda Hacks. All rights reserved. +

+

+ You received this email because you applied for Garuda Hacks 6.0. +

+
+
+ + + `, + text: `Welcome to Garuda Hacks 6.0! + +πŸŽ‰ Congratulations! +On behalf of the entire Garuda Hacks team, we're excited to welcome you to Garuda Hacks 6.0, happening from 24 to 26 July 2025 at Universitas Multimedia Nusantara (UMN). We're so glad to have you on board. + +This year's event is proudly presented in collaboration with Himpunan Mahasiswa Informatika Universitas Multimedia Nusantara (HMIF UMN), who have graciously offered their beautiful campus and full support to ensure a smooth and enjoyable experience for all participants. + +This year is an exciting time to be joining Garuda Hacks. When we began planning for this year's event, we collected feedback from previous years and decided to focus on the participant experience. From a career fair and a networking lunch, to a live judging round with VCs and a revamped application portal, we are determined to make this year our most engaging event yet. We hope that you will enjoy the experience. + +At the same time, we understand the world is going through challenging times. In moments of uncertainty, it is often the smallest communities that feel the greatest impact. We hope this event will be a space for you to build, connect, and be reminded of our shared mission as creators: to drive meaningful impact within our communities. We hope that this motivation will empower you to consistently think about what you can do to serve people and drive positive and lasting change for them. + +--- + +EVENT DETAILS +- Date: July 24 - 26, 2025 +- Location: Universitas Multimedia Nusantara (UMN) +- Duration: 30 Hours Hacking Period and Finalist Demo Day + +--- + +ACTION ITEMS +1. RSVP on Portal to confirm your participation: https://portal.garudahacks.com/ +2. If you're under 18, sign and upload the Underage Consent Form on the RSVP Portal +3. Join our Discord community for updates and networking: https://discord.gg/vQw3UeYzFb +4. Follow @garudahacks on Instagram for latest updates: https://www.instagram.com/garudahacks +5. Add the official Garuda Hacks 6.0 Twibbon to your social media: https://twibbo.nz/garudahacks6 +6. Prepare your development environment and tools +7. Attend our technical meeting and speed dating sessions (details on Discord & Instagram) +8. Mark your calendar and get ready to hack! + +--- + +Once again, on behalf of the entire Garuda Hacks committee, sponsors, and partners, WELCOME! We cannot wait to see what you will create! + +Maria Gracia Athalia & Dominic Kartadjoemena +Co-Managing Directors, Garuda Hacks 6.0 + +Questions? Contact us at hiba@garudahacks.com +Follow us on social media for the latest updates. + +Β© 2025 Garuda Hacks. All rights reserved. +You received this email because you applied for Garuda Hacks 6.0. +`, +}); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const createRejectionMailOptions = (email: string): MailOptions => ({ + from: { + name: "Garuda Hacks", + address: "no-reply@garudahacks.com", + }, + to: email, + subject: "Your Garuda Hacks 6.0 Application Status", + html: ` + + + + + + Your Garuda Hacks 6.0 Application Status + + + + + + + +
+ + + + + + + +
+

+ Garuda
Hacks 6.0 +

+

+ Application Update +

+
+
+

+ Thank you so much for the thought and care that you have put into your application. + The Garuda Hacks team has reviewed your application, and unfortunately we are not able to + invite you to join Garuda Hacks 6.0 at this time. +

+

+ This is not an easy decision to make. We at Garuda Hacks have always prided + ourselves on making an inclusive event where anyone can join. In the past we have + always been able to do thisβ€”initially through our online format and the venues we + have been honored to partner with. However, this year, for the first time, demand + far outpaced the number of people we can accept, and so we have been forced to + reject many well-qualified applicants. +

+

+ We are not able to provide individual feedback on each application. However, we + suggest that you apply earlier in the coming years, as applications are read on a + rolling basis. +

+

+ We hope that you understand that this decision does not reflect how the committee + sees your achievements, potential, or character. We believe that the hackathon + experience is amplified as more people come to solve problems and ideate together. + Perhaps someday we will be able to accommodate all 1,500 applicants. However, we are + constrained by capacity and regulations. +

+

+ All our best,
+ The Garuda Hacks 6.0 Committee +

+
+
+ + + + +
+

+ Β© 2025 Garuda Hacks. All rights reserved. +

+

+ You received this email because you applied for Garuda Hacks 6.0. +

+
+
+ + + `, + text: `Your Garuda Hacks 6.0 Application Status\n\nThank you so much for the thought and care that you have put into your application.\n\nThe Garuda Hacks team has reviewed your application, and unfortunately we are not able to invite you to join Garuda Hacks 6.0 at this time.\n\nThis is not an easy decision to make. We at Garuda Hacks have always prided ourselves on making an inclusive event where anyone can join. In the past we have always been able to do thisβ€”initially through our online format and the venues we have been honored to partner with. However, this year, for the first time, demand far outpaced the number of people we can accept, and so we have been forced to reject many well-qualified applicants.\n\nWe are not able to provide individual feedback on each application. However, we suggest that you apply earlier in the coming years, as applications are read on a rolling basis.\n\nWe hope that you understand that this decision does not reflect how the committee sees your achievements, potential, or character. We believe that the hackathon experience is amplified as more people come to solve problems and ideate together. Perhaps someday we will be able to accommodate all 1,500 applicants. However, we are constrained by capacity and regulations.\n\nAll our best, The Garuda Hacks 6.0 Committee\n\nΒ© 2025 Garuda Hacks. All rights reserved.\nYou received this email because you applied for Garuda Hacks 6.0.`, +}); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const createWaitlistedMailOptions = (email: string): MailOptions => ({ + from: { + name: "Garuda Hacks", + address: "no-reply@garudahacks.com", + }, + to: email, + subject: "Your Garuda Hacks 6.0 Application Status: Waitlisted", + html: ` + + + + + + Your Garuda Hacks 6.0 Application Status: Waitlisted + + + + + + + +
+ + + + + + + +
+

+ Garuda
Hacks 6.0 +

+

+ Application Update +

+
+
+

+ You're on the Waitlist! +

+

+ Thank you for your interest in Garuda Hacks 6.0. At this time, you have been placed on our waitlist. Spots may open up as the event approaches, and we will notify you immediately if a spot becomes available. +

+

+ We appreciate your patience and enthusiasm. Stay tuned for updates! +

+
+
+

+ If you have any questions, feel free to reach out to us at hiba@garudahacks.com. +

+

+ We hope to see you at Garuda Hacks 6.0! +

+
+
+ + + + +
+

+ Β© 2025 Garuda Hacks. All rights reserved. +

+

+ You received this email because you applied for Garuda Hacks 6.0. +

+
+
+ + + `, + text: `Your Garuda Hacks 6.0 Application Status: Waitlisted\n\nThank you for your interest in Garuda Hacks 6.0. At this time, you have been placed on our waitlist. Spots may open up as the event approaches, and we will notify you immediately if a spot becomes available.\n\nWe appreciate your patience and enthusiasm. Stay tuned for updates!\n\nIf you have any questions, feel free to reach out to us at hiba@garudahacks.com.\n\nWe hope to see you at Garuda Hacks 6.0!\n\nΒ© 2025 Garuda Hacks. All rights reserved.\nYou received this email because you applied for Garuda Hacks 6.0.`, +}); + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { email, type } = body; + + if (!email) { + return NextResponse.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + const transporter = nodemailer.createTransport({ + host: "live.smtp.mailtrap.io", + port: 587, + auth: { + user: process.env.MAILTRAP_USER, + pass: process.env.MAILTRAP_PASS, + }, + }); + + let mailOptions; + if (type === "rejected") { + mailOptions = createRejectionMailOptions(email); + } else if (type === "waitlisted") { + mailOptions = createWaitlistedMailOptions(email); + } else { + mailOptions = createAcceptanceMailOptions(email); + } + + await transporter.sendMail(mailOptions); + + return NextResponse.json({ message: "Email sent successfully" }); + } catch (error) { + console.error(error); + const errorMessage = + error instanceof Error ? error.message : "An unknown error occurred"; + return NextResponse.json( + { error: "Failed to send email", details: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/applications/page.tsx b/app/applications/page.tsx index 44a22e5..a2e6231 100644 --- a/app/applications/page.tsx +++ b/app/applications/page.tsx @@ -1,9 +1,1079 @@ -import React from 'react' +"use client"; + +import { useState, useEffect } from "react"; +import PageHeader from "@/components/PageHeader"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import { + fetchApplicationsWithUsers, + getEducationLevel, + formatApplicationDate, + getYearSuffix, + debugAuthToken, + updateUserStatus, + updateApplicationScore, + getQuestionText, + getPortalConfig, +} from "@/lib/firebaseUtils"; +import { + CombinedApplicationData, + APPLICATION_STATUS, + PortalConfig, +} from "@/lib/types"; +import ApplicationAcceptModal from "@/components/ApplicationAcceptModal"; +import { calculateAge } from "@/lib/evaluator"; + +export default function Applications() { + const [config, setConfig] = useState(null); + const [applications, setApplications] = useState( + [] + ); + const [applicationsOriginal, setApplicationsOriginal] = useState< + CombinedApplicationData[] + >([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedApplication, setSelectedApplication] = + useState(null); + const [evaluationScore, setEvaluationScore] = useState(""); + const [evaluationNotes, setEvaluationNotes] = useState(""); + const [rejecting, setRejecting] = useState(false); + const [accepting, setAccepting] = useState(false); + const [showAcceptModal, setShowAcceptModal] = useState(false); + const [questionTexts, setQuestionTexts] = useState<{ + motivation: string; + bigProblem: string; + interestingProject: string; + listTeammates: string; + }>({ + motivation: "Motivation", + bigProblem: "Problem to Solve", + interestingProject: "Interesting Project", + listTeammates: "List of Teammates", + }); + const [searchName, setSearchName] = useState(""); + const [searchSort, setSearchSort] = useState(""); + const [isSortDescending, setIsSortDescending] = useState(false); + + const onChangeSearchQuery = (e: React.ChangeEvent) => { + setSearchName(e.target.value); + + // possible for gender + const genderFiltered = applicationsOriginal.filter((app) => + app.gender_identity?.toLowerCase().includes(e.target.value.toLowerCase()) + ); + + // for university + const uniFiltered = applicationsOriginal.filter((app) => + app.school?.toLowerCase().includes(e.target.value.toLowerCase()) + ); + + // for status + const statusFiltered = applicationsOriginal.filter((app) => + app.status?.toLowerCase().includes(e.target.value.toLowerCase()) + ); + + // for name + const nameFiltered = applicationsOriginal.filter((app) => + app.firstName?.toLowerCase().includes(e.target.value.toLowerCase()) + ); + + // for last name + const lastNameFiltered = applicationsOriginal.filter((app) => + app.lastName?.toLowerCase().includes(e.target.value.toLowerCase()) + ); + + // for email + const emailFiltered = applicationsOriginal.filter((app) => + app.email?.toLowerCase().includes(e.target.value.toLowerCase()) + ); + + // for desired role + const roleFiltered = applicationsOriginal.filter((app) => + app.desiredRoles?.toLowerCase().includes(e.target.value.toLowerCase()) + ); + + // for age + const ageFiltered = applicationsOriginal.filter((app) => { + const age = calculateAge(app.date_of_birth); + return age.toString().includes(e.target.value); + }); + + // for year + const schoolYearFiltered = applicationsOriginal.filter((app) => + app.year?.toString().includes(e.target.value) + ); + + const allResults = genderFiltered.concat( + uniFiltered, + statusFiltered, + nameFiltered, + lastNameFiltered, + emailFiltered, + roleFiltered, + ageFiltered, + schoolYearFiltered, + schoolYearFiltered + ); + const uniqueResults = allResults.filter( + (app, index, self) => index === self.findIndex((a) => a.id === app.id) + ); + setApplications(uniqueResults); + }; + + const getSortValue = (app: CombinedApplicationData, sortField: string) => { + switch (sortField) { + case "score": + return app.score || 0; + case "applicationCreatedAt": + return new Date(app.applicationCreatedAt).getTime(); + case "applicationUpdatedAt": + return new Date(app.applicationUpdatedAt).getTime(); + case "email": + return app.email; + case "firstName": + return app.firstName || ""; + case "lastName": + return app.lastName || ""; + default: + return ""; + } + }; + + const applySorting = (sortField: string, descending: boolean = false) => { + if (sortField === "none") { + setApplications([...applicationsOriginal]); + return; + } + + const sorted = [...applications].sort((a, b) => { + const aValue = getSortValue(a, sortField); + const bValue = getSortValue(b, sortField); + + if (typeof aValue === "string" && typeof bValue === "string") { + return descending + ? bValue.localeCompare(aValue) + : aValue.localeCompare(bValue); + } + + if (typeof aValue === "number" && typeof bValue === "number") { + return descending ? bValue - aValue : aValue - bValue; + } + + return 0; + }); + + setApplications(sorted); + }; + + const onChangeSearchSort = (e: React.ChangeEvent) => { + setSearchSort(e.target.value); + applySorting(e.target.value, isSortDescending); + }; + + const onChangeIsSortDescending = () => { + const newIsSortDescending = !isSortDescending; + setIsSortDescending(newIsSortDescending); + applySorting(searchSort, newIsSortDescending); + }; + + useEffect(() => { + loadConfig(); + loadApplications(); + loadQuestionTexts(); + }, []); + + const loadConfig = async () => { + try { + setLoading(true); + const portalConfig = await getPortalConfig(); + setConfig(portalConfig); + } catch { + setError("Failed to load portal configuration"); + } finally { + setLoading(false); + } + }; + + const loadApplications = async () => { + try { + setLoading(true); + setError(null); + + await debugAuthToken(); + + const data = await fetchApplicationsWithUsers(); + setApplications(data); + setApplicationsOriginal(data); + if (data.length > 0) { + setSelectedApplication(data[0]); + setEvaluationScore(data[0].score?.toString() || ""); + setEvaluationNotes(data[0].evaluationNotes || ""); + } + } catch (err) { + console.error("Error loading applications:", err); + setError("Failed to load applications. Please try again."); + } finally { + setLoading(false); + } + }; + + const loadQuestionTexts = async () => { + try { + const [ + motivationText, + bigProblemText, + interestingProjectText, + listTeammatesText, + ] = await Promise.all([ + getQuestionText("motivation"), + getQuestionText("bigProblem"), + getQuestionText("interestingProject"), + getQuestionText("list_teammates"), + ]); + + setQuestionTexts({ + motivation: motivationText, + bigProblem: bigProblemText, + interestingProject: interestingProjectText, + listTeammates: listTeammatesText, + }); + } catch (error) { + console.error("Error loading question texts:", error); + } + }; + + const handleApplicationSelect = (application: CombinedApplicationData) => { + setSelectedApplication(application); + setEvaluationScore(application.score?.toString() || ""); + setEvaluationNotes(application.evaluationNotes || ""); + }; + + const handleScoreSubmit = async () => { + if (!selectedApplication) return; + + const score = parseFloat(evaluationScore); + if (score >= 0 && score <= (config?.maxApplicationEvaluationScore || 20)) { + try { + const success = await updateApplicationScore( + selectedApplication.id, + score, + evaluationNotes + ); + + if (success) { + // Update local state to reflect the changes + setApplications((prev) => + prev.map((app) => + app.id === selectedApplication.id + ? { ...app, score, evaluationNotes } + : app + ) + ); + + // Update selected application + setSelectedApplication((prev) => + prev ? { ...prev, score, evaluationNotes } : null + ); + + // Clear the notes field after successful submission + setEvaluationNotes(""); + } else { + console.error("Failed to save score and notes"); + } + } catch (error) { + console.error("Error saving score:", error); + } + } + }; + + const handleRejectParticipant = async () => { + if (!selectedApplication) return; + + try { + setRejecting(true); + const success = await updateUserStatus( + selectedApplication.id, + APPLICATION_STATUS.REJECTED + ); + + if (success) { + try { + const response = await fetch("/api/send-email", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: selectedApplication.email, + type: "rejected", + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error("Failed to send rejection email:", errorData); + } + } catch (emailError) { + console.error("Error sending rejection email:", emailError); + } + + setApplications((prev) => + prev.map((app) => + app.id === selectedApplication.id + ? { ...app, status: APPLICATION_STATUS.REJECTED } + : app + ) + ); + + setSelectedApplication((prev) => + prev ? { ...prev, status: APPLICATION_STATUS.REJECTED } : null + ); + } else { + console.error("Failed to reject participant"); + } + } catch (error) { + console.error("Error rejecting participant:", error); + } finally { + setRejecting(false); + } + }; + + const handleAcceptParticipant = async () => { + if (!selectedApplication) return; + + try { + setAccepting(true); + const success = await updateUserStatus( + selectedApplication.id, + APPLICATION_STATUS.ACCEPTED + ); + + if (success) { + try { + const response = await fetch("/api/send-email", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: selectedApplication.email, + type: "accepted", + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error("Failed to send acceptance email:", errorData); + } + } catch (emailError) { + console.error("Error sending acceptance email:", emailError); + } + + setApplications((prev) => + prev.map((app) => + app.id === selectedApplication.id + ? { ...app, status: APPLICATION_STATUS.ACCEPTED } + : app + ) + ); + + setSelectedApplication((prev) => + prev ? { ...prev, status: APPLICATION_STATUS.ACCEPTED } : null + ); + } else { + console.error("Failed to accept participant"); + } + } catch (error) { + console.error("Error accepting participant:", error); + } finally { + setAccepting(false); + } + }; + + const handleWaitlistParticipant = async () => { + if (!selectedApplication) return; + + try { + setRejecting(true); + const success = await updateUserStatus( + selectedApplication.id, + APPLICATION_STATUS.WAITLISTED + ); + + if (success) { + try { + const response = await fetch("/api/send-email", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: selectedApplication.email, + type: "waitlisted", + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + console.error("Failed to send waitlist email:", errorData); + } + } catch (emailError) { + console.error("Error sending waitlist email:", emailError); + } + + setApplications((prev) => + prev.map((app) => + app.id === selectedApplication.id + ? { ...app, status: APPLICATION_STATUS.WAITLISTED } + : app + ) + ); + + setSelectedApplication((prev) => + prev ? { ...prev, status: APPLICATION_STATUS.WAITLISTED } : null + ); + } else { + console.error("Failed to waitlist participant"); + } + } catch (error) { + console.error("Error waitlisting participant:", error); + } finally { + setRejecting(false); + } + }; + + const getDisplayStatus = (application: CombinedApplicationData): string => { + if ( + application.status === APPLICATION_STATUS.SUBMITTED && + application.score + ) { + return APPLICATION_STATUS.GRADED; + } + return application.status; + }; + + const getStatusColor = (status: string) => { + switch (status) { + case APPLICATION_STATUS.ACCEPTED: + return "bg-accent-foreground/20 text-accent-accessible"; + case APPLICATION_STATUS.REJECTED: + return "bg-destructive/20 text-violet-600"; + case APPLICATION_STATUS.SUBMITTED: + return "bg-secondary/20 text-fuchsia-500"; + case APPLICATION_STATUS.GRADED: + return "bg-blue-500/20 text-blue-400"; + case APPLICATION_STATUS.WAITLISTED: + return "bg-yellow-500/20 text-violet-500"; + case APPLICATION_STATUS.CONFIRMED_RSVP: + return "bg-green-500/20 text-purple-500"; + default: + return "bg-white/10 text-white/70"; + } + }; + + const getStatusTextColor = (status: string) => { + const colorClasses = getStatusColor(status); + const textColorMatch = colorClasses.match(/text-[\w-\/]+/); + return textColorMatch ? textColorMatch[0] : "text-white/70"; + }; + + const getStatusBadgeClasses = (status: string) => { + switch (status) { + case APPLICATION_STATUS.ACCEPTED: + return "bg-accent-accessible/20 text-accent-accessible border-accent-accessible/50"; + case APPLICATION_STATUS.REJECTED: + return "bg-violet-800/20 text-violet-800 border-violet-800/50"; + case APPLICATION_STATUS.SUBMITTED: + return "bg-fuchsia-500/20 text-fuchsia-500 border-fuchsia-500/50"; + case APPLICATION_STATUS.GRADED: + return "bg-blue-500/20 text-blue-400 border-blue-500/50"; + case APPLICATION_STATUS.WAITLISTED: + return "bg-violet-500/20 text-violet-500 border-violet-500/50"; + case APPLICATION_STATUS.CONFIRMED_RSVP: + return "bg-purple-500/20 text-purple-500 border-purple-500/50"; + default: + return "bg-white/10 text-white/70 border-white/30"; + } + }; + + if (loading) { + return ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ +
+
{error}
+ +
+
+ ); + } + + const pendingApplications = applications.filter( + (app) => app.status === APPLICATION_STATUS.SUBMITTED && !app.score + ); + const approvedApplications = applications.filter( + (app) => app.status === APPLICATION_STATUS.ACCEPTED + ); + const waitlistedApplications = applications.filter( + (app) => app.status === APPLICATION_STATUS.WAITLISTED + ); + const rejectedApplications = applications.filter( + (app) => app.status === APPLICATION_STATUS.REJECTED + ); + const displayableApplications = applications.filter( + (app) => app.status !== APPLICATION_STATUS.NOT_APPLICABLE + ); + const confirmedRSVPApplications = applications.filter( + (app) => app.status === APPLICATION_STATUS.CONFIRMED_RSVP + ); -function ApplicationsPage() { return ( -
ApplicationsPage
- ) -} +
+ + +
+
+
+
+
+
+ {pendingApplications.length} +
+
Pending
+
+ {/*
+
+ {waitlistedApplications.length} +
+
Waitlist
+
*/} +
+
+ {rejectedApplications.length} +
+
Rejected
+
+
+
+ {approvedApplications.length} +
+
Accepted
+
+
+
+ {confirmedRSVPApplications.length} +
+
Confirmed RSVP
+
+
+
+
+
+ +

+ Support name, email, status, university, gender, role, age, + school year. +

+ +
+
+

Sort by

+ +
+
+

Desc

+ +
+
+
+
+

+ Applications List ({displayableApplications.length}) +

+
+
+ {/* */} + {displayableApplications.length === 0 ? ( +
+ No applications found +
+ ) : ( + displayableApplications.map((application) => ( +
handleApplicationSelect(application)} + className={`w-full max-w-full p-4 border-b border-white/10 cursor-pointer transition-colors hover:bg-white/5 ${ + selectedApplication?.id === application.id + ? "bg-primary/10 border-primary/30" + : "" + }`} + > +
+

+ {application.firstName} {application.lastName} +

+
+ {application.score ? ( +
+ {application.score}/ + {config?.maxApplicationEvaluationScore || 20} +
+ ) : ( +
+ Not scored +
+ )} +
+
+
+ + {getDisplayStatus(application)} + +
+
+ )) + )} +
+
+ + +
+ +
+
+
+

+ Application Evaluator +

+
+
+ {selectedApplication ? ( +
+
+
+

+ {selectedApplication.firstName} +

+
+

+ Email:{" "} + {selectedApplication.email} +

+

+ Gender:{" "} + {selectedApplication.gender_identity} +

+

+ School:{" "} + {selectedApplication.school} +

+

+ Education:{" "} + {getEducationLevel(selectedApplication.education)} +

+

+ Year:{" "} + {getYearSuffix(selectedApplication.year)} +

+

+ Age:{" "} + {calculateAge(selectedApplication.date_of_birth)} +

+

+ Hackathons:{" "} + {selectedApplication.hackathonCount} previous +

+

+ Desired Roles:{" "} + {selectedApplication.desiredRoles} +

+
+
+
+
+ Links & Documents +
+
+ {selectedApplication.resume && ( + + + + + Resume (PDF) + + )} + {selectedApplication.portfolio && + selectedApplication.portfolio !== "X" && ( + + + + + Portfolio + + )} + {selectedApplication.github && + selectedApplication.github !== "X" && ( + + + + + GitHub + + )} + {selectedApplication.linkedin && + selectedApplication.linkedin !== "X" && ( + + + + + LinkedIn + + )} +
+
+
+ +
+
+ {questionTexts.motivation} +
+