diff --git a/apps/blade/src/app/_components/auth-home.tsx b/apps/blade/src/app/_components/auth-home.tsx index bc6c7e681..1b7742024 100644 --- a/apps/blade/src/app/_components/auth-home.tsx +++ b/apps/blade/src/app/_components/auth-home.tsx @@ -15,10 +15,10 @@ export function AuthHome() { Manage your Knight Hacks membership, hackathon information, and more with Blade.

- - diff --git a/apps/blade/src/app/_components/hero.tsx b/apps/blade/src/app/_components/hero.tsx index 941920c1c..681dcee67 100644 --- a/apps/blade/src/app/_components/hero.tsx +++ b/apps/blade/src/app/_components/hero.tsx @@ -29,6 +29,7 @@ export function Hero() {
-
+
-
+
- - - - Past Hackathons Attended - -
- {hackathons.slice(1).map((hackathon) => ( - - - {hackathon.name} - - - {hackathon.theme} - - - -
- - - {formatDateRange( - hackathon.startDate, - hackathon.endDate, - )} - -
-
-
- ))} -
- -
- - - - ); -} - -*/ diff --git a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-dashboard.tsx b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-dashboard.tsx index 601e0b21e..f215204e0 100644 --- a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-dashboard.tsx +++ b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-dashboard.tsx @@ -2,20 +2,74 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import type { api as serverCall } from "~/trpc/server"; +import { api } from "~/trpc/server"; +import { HackerData } from "./hacker-data"; +import { HackerResumeButton } from "./hacker-resume-button"; +import { PastHackathonButton } from "./past-hackathons"; export const metadata: Metadata = { title: "Hacker Dashboard", description: "The official Knight Hacks Hacker Dashboard", }; -export default function HackerDashboard({ +export default async function HackerDashboard({ hacker, }: { hacker: Awaited>; }) { if (!hacker) { - redirect("/"); + redirect("/hacker/application"); } - return
Work in progress...
; + const [resume, pastHackathons] = await Promise.allSettled([ + api.resume.getResume(), + api.hacker.getHackathons(), + ]); + + return ( + <> +
+ {/* Main content */} + + + {/* Transparent Triangle overlay in bottom right corner */} +
+ + {/* Triangle in bottom right corner */} +
+ + {/* Top rectangle */} +
+
+
+ + {/* Bottom rectangle */} +
+
+
+ + {/* Left side rectangle */} +
+
+
+ {resume.status === "rejected" || + pastHackathons.status === "rejected" ? ( +
+ Something went wrong. Please try again later. +
+ ) : ( + <> + + + + )} +
+ + ); } diff --git a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-data.tsx b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-data.tsx new file mode 100644 index 000000000..619df9799 --- /dev/null +++ b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-data.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { CircleCheckBig, Loader2 } from "lucide-react"; + +import { USE_CAUTION } from "@forge/consts/knight-hacks"; +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@forge/ui/dialog"; +import { Input } from "@forge/ui/input"; +import { toast } from "@forge/ui/toast"; + +import type { api as serverCall } from "~/trpc/server"; +import { HACKER_STATUS_MAP } from "~/consts"; +import { api } from "~/trpc/react"; +import { HackerQRCodePopup } from "./hacker-qr-button"; + +type StatusKey = keyof typeof HACKER_STATUS_MAP | null | undefined; + +export function HackerData({ + data, +}: { + data: Awaited>; +}) { + const [hackerStatus, setHackerStatus] = useState(""); + const [hackerStatusColor, setHackerStatusColor] = useState(""); + const [loading, setLoading] = useState(false); + const [confirmationText, setConfirmationText] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + + const { data: hacker, isError } = api.hacker.getHacker.useQuery(undefined, { + initialData: data, + }); + + const utils = api.useUtils(); + + const handleConfirm = () => { + setLoading(true); + confirmHacker.mutate({ + id: hacker?.id, + }); + }; + + const handleWithdraw = () => { + setLoading(true); + withdrawHacker.mutate({ + id: hacker?.id, + }); + }; + + function getStatusName(status: StatusKey) { + if (!status) return ""; + return HACKER_STATUS_MAP[status].name; + } + + function getStatusColor(status: StatusKey) { + if (!status) return ""; + return HACKER_STATUS_MAP[status].color; + } + + const confirmHacker = api.hacker.confirmHacker.useMutation({ + async onSuccess() { + setHackerStatus("Confirmed"); + setHackerStatusColor(getStatusColor("confirmed")); + setIsConfirmOpen(true); + await utils.hacker.getHacker.invalidate(); + }, + onError() { + toast.error("Oops! Something went wrong. Please try again later."); + }, + onSettled() { + setLoading(false); + }, + }); + + const withdrawHacker = api.hacker.withdrawHacker.useMutation({ + async onSuccess() { + setHackerStatus("Withdrawn"); + setHackerStatusColor(getStatusColor("withdrawn")); + toast.success("You have withdrawn from the hackathon!"); + await utils.hacker.getHacker.invalidate(); + }, + onError() { + toast.error("Oops! Something went wrong. Please try again later."); + }, + onSettled() { + setLoading(false); + }, + }); + + useEffect(() => { + setHackerStatus(getStatusName(hacker?.status)); + setHackerStatusColor(getStatusColor(hacker?.status)); + }, [hacker]); + + if (isError) { + return ( +
+ Something went wrong. Please refresh and try again. +
+ ); + } + + return ( +
+
+ Image of TK +
+
+
+
Status
+
+
+ {hackerStatus} +
+ {hackerStatus === "Confirmed" && ( + + )} +
+
+
+
Class
+
+ TBD +
+
+
+
+ + {/* Confirm Button */} + {hackerStatus === "Accepted" && ( + + )} + {/* Confirm Dialog */} + + + + + Congratulations! + + +
+ Image of TK + + You've successfully confirmed for the hackathon. We're excited + to see you there! + +
+ + + +
+
+ {/* Withdraw Button */} + {hackerStatus === "Confirmed" && ( + + + + + + + + Are you sure? + + You are about to withdraw from this hackathon. This action + cannot be undone. Please proceed with caution. + + + +
+

+ Please type "I am absolutely sure" to + confirm: +

+ setConfirmationText(e.target.value)} + onPaste={(e) => { + e.preventDefault(); + toast.info("Please type in the text, do not paste."); + }} + /> +
+ + + + + +
+
+ )} +
+
+ ); +} diff --git a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-qr-button.tsx b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-qr-button.tsx new file mode 100644 index 000000000..9aae7c6d3 --- /dev/null +++ b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-qr-button.tsx @@ -0,0 +1,85 @@ +"use client"; + +import Image from "next/image"; +import { Loader2, QrCode } from "lucide-react"; + +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@forge/ui/dialog"; + +import { api } from "~/trpc/react"; + +export function HackerQRCodePopup() { + const getQR = () => { + const { data: userQR, isLoading, isError } = api.qr.getQRCode.useQuery(); + + if (isError) { + return ( +
+
+ Something went wrong. please try again +
+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (userQR?.qrCodeUrl) { + return ( +
+ QR Code +
+ ); + } + + return ( +
+
No QR Code found.
+
+ ); + }; + return ( + + + + + + + Your QR Code + +
+
{getQR()}
+
+
+ +
+ ); +} diff --git a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-resume-button.tsx b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-resume-button.tsx new file mode 100644 index 000000000..205afee74 --- /dev/null +++ b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hacker-resume-button.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; +import { Download, Upload } from "lucide-react"; + +import type { api as serverCall } from "~/trpc/server"; + +export function HackerResumeButton({ + resume, +}: { + resume: Awaited>; +}) { + if (resume.url == null) { + return ( + +
+ +
Upload Resume
+
+ + ); + } + + return ( + +
+ +
Download Resume
+
+ + ); +} diff --git a/apps/blade/src/app/dashboard/_components/hacker-dashboard/past-hackathons.tsx b/apps/blade/src/app/dashboard/_components/hacker-dashboard/past-hackathons.tsx new file mode 100644 index 000000000..2e4ca1b4b --- /dev/null +++ b/apps/blade/src/app/dashboard/_components/hacker-dashboard/past-hackathons.tsx @@ -0,0 +1,115 @@ +import { Eye, Users } from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@forge/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@forge/ui/dialog"; + +import type { api } from "~/trpc/server"; +import { formatDateTime } from "~/lib/utils"; + +export function PastHackathonButton({ + hackathons, +}: { + hackathons: Awaited>; +}) { + const mostRecent = hackathons[0]; + + if (!mostRecent) { + return ( + +
+ +
+ +
View Past Hackathons
+
+
+
+ + + Past Hackathons Attended + +
+
No hackathons found!
+
+ +
+
+ ); + } + + return ( + +
+ +
+ +
View Past Hackathons
+
+
+
+ + + Past Hackathons Attended + +
+ {hackathons.map((hackathon) => ( + + {/* Transparent Triangle overlay */} +
+ +
+
+ {hackathon.name} +
+
+
+ +
+
+
+
+ Start + + {formatDateTime(hackathon.startDate)} + +
+ +
+ End + + {formatDateTime(hackathon.endDate)} + +
+
+
+
+ + + {hackathon.numAttended}{" "} + {hackathon.numAttended === 1 ? "Attendee" : "Attendees"} + +
+
+ Theme + {hackathon.theme} +
+
+
+
+ ))} +
+ +
+
+ ); +} diff --git a/apps/blade/src/app/globals.css b/apps/blade/src/app/globals.css index 1a8c6c349..feda86387 100644 --- a/apps/blade/src/app/globals.css +++ b/apps/blade/src/app/globals.css @@ -73,3 +73,32 @@ transform: rotate(-360deg) scale(10); } } + +.animate-mobile-initial-expand { + animation: initialMobileExpand 0.5s ease-out forwards; +} + +@keyframes initialMobileExpand { + 0% { + height: 0px; + } + 100% { + height: 26rem; + } +} + +.animate-fade-in { + animation: fadeIn 0.5s ease-in forwards; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 70% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/apps/blade/src/consts/index.ts b/apps/blade/src/consts/index.ts index 55b48b81d..d04db351c 100644 --- a/apps/blade/src/consts/index.ts +++ b/apps/blade/src/consts/index.ts @@ -15,3 +15,13 @@ export const SIDEBAR_NAV_ITEMS = [ export const USER_DROPDOWN_ICON_COLOR = "hsl(263.4 70% 50.4%)"; //lucide only works with HSL values export const USER_DROPDOWN_ICON_SIZE = 20; + +export const HACKER_STATUS_MAP = { + withdrawn: { name: "Withdrawn", color: "text-[#FF6B6B]" }, + pending: { name: "Pending", color: "text-[#FFD93D]" }, + accepted: { name: "Accepted", color: "text-[#6BCB77]" }, + waitlisted: { name: "Waitlisted", color: "text-[#4D96FF]" }, + checkedin: { name: "Checked-in", color: "text-[#845EC2]" }, + confirmed: { name: "Confirmed", color: "text-[#00C9A7]" }, + denied: { name: "Denied", color: "text-[#FF5E5E]" }, +}; diff --git a/packages/api/src/routers/hacker.ts b/packages/api/src/routers/hacker.ts index 57f7f674e..8aa899dd6 100644 --- a/packages/api/src/routers/hacker.ts +++ b/packages/api/src/routers/hacker.ts @@ -2,9 +2,10 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { and, eq, exists } from "@forge/db"; +import { and, count, desc, eq, exists, getTableColumns } from "@forge/db"; import { db } from "@forge/db/client"; import { + Hackathon, Hacker, HackerAttendee, InsertHackerSchema, @@ -198,4 +199,111 @@ export const hackerRouter = { userId: ctx.session.user.discordUserId, }); }), + + confirmHacker: protectedProcedure + .input(InsertHackerSchema.pick({ id: true })) + .mutation(async ({ input }) => { + if (!input.id) { + throw new TRPCError({ + message: "Hacker ID is required to update a member!", + code: "BAD_REQUEST", + }); + } + + const { id } = input; + + const hacker = await db.query.Hacker.findFirst({ + where: (t, { eq }) => eq(t.id, id), + }); + + if (!hacker) { + throw new TRPCError({ + message: "Hacker not found!", + code: "NOT_FOUND", + }); + } + + if (hacker.status === "confirmed") { + throw new TRPCError({ + message: "Hacker has already been confirmed!", + code: "UNAUTHORIZED", + }); + } else if (hacker.status !== "accepted") { + throw new TRPCError({ + message: "Hacker has not been accepted!", + code: "UNAUTHORIZED", + }); + } + + await db + .update(Hacker) + .set({ + status: "confirmed", + }) + .where(eq(Hacker.id, id)); + }), + + withdrawHacker: protectedProcedure + .input(InsertHackerSchema.pick({ id: true })) + .mutation(async ({ input }) => { + if (!input.id) { + throw new TRPCError({ + message: "Hacker ID is required to update a member!", + code: "BAD_REQUEST", + }); + } + + const { id } = input; + + const hacker = await db.query.Hacker.findFirst({ + where: (t, { eq }) => eq(t.id, id), + }); + + if (!hacker) { + throw new TRPCError({ + message: "Hacker not found!", + code: "NOT_FOUND", + }); + } + + if (hacker.status !== "confirmed") { + throw new TRPCError({ + message: "Hacker is not confirmed!", + code: "UNAUTHORIZED", + }); + } + + await db + .update(Hacker) + .set({ + status: "withdrawn", + }) + .where(eq(Hacker.id, id)); + }), + + getHackathons: protectedProcedure.query(async ({ ctx }) => { + // Get each hackathon and numAttended + const hackathonsSubQuery = db + .select({ + id: Hackathon.id, + numAttended: count(HackerAttendee.id).as("numAttended"), + }) + .from(Hackathon) + .leftJoin(HackerAttendee, eq(Hackathon.id, HackerAttendee.hackathonId)) + .groupBy(Hackathon.id) + .as("hackathonsSubQuery"); + + const hackathons = await db + .select({ + ...getTableColumns(Hackathon), + numAttended: hackathonsSubQuery.numAttended, + }) + .from(Hackathon) + .leftJoin(HackerAttendee, eq(Hackathon.id, HackerAttendee.hackathonId)) + .leftJoin(Hacker, eq(HackerAttendee.hackerId, Hacker.id)) + .leftJoin(hackathonsSubQuery, eq(hackathonsSubQuery.id, Hackathon.id)) // Add numAttended to each corresponding event + .where(eq(Hacker.userId, ctx.session.user.id)) + .orderBy(desc(Hackathon.startDate)); + return hackathons; + }), } satisfies TRPCRouterRecord;