+ Something went wrong. Please try again later.
+
+ );
+ }
+
+ if (!member.value && !hacker.value) {
return (
@@ -21,7 +31,7 @@ export async function UserInterface() {
return (
@@ -39,10 +49,10 @@ export async function UserInterface() {
-
+
-
-
+
+
diff --git a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hackathon/hackathon-number.tsx b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hackathon/hackathon-number.tsx
deleted file mode 100644
index daa8f7ce1..000000000
--- a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hackathon/hackathon-number.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Trophy } from "lucide-react";
-
-import { Card, CardContent, CardHeader, CardTitle } from "@forge/ui/card";
-
-import { DASHBOARD_ICON_SIZE } from "~/consts";
-
-export function HackathonNumber({ size }: { size: number }) {
- return (
-
-
-
- Hackathons Attended
-
-
-
-
- {size}
- All academic year
-
-
- );
-}
diff --git a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hackathon/hackathon-showcase.tsx b/apps/blade/src/app/dashboard/_components/hacker-dashboard/hackathon/hackathon-showcase.tsx
deleted file mode 100644
index ecc367443..000000000
--- a/apps/blade/src/app/dashboard/_components/hacker-dashboard/hackathon/hackathon-showcase.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-/* import { CalendarDays, Tag, Trophy } from "lucide-react";
-
-import { Button } from "@forge/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@forge/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@forge/ui/dialog";
-
-import type { api } from "~/trpc/server";
-import { DASHBOARD_ICON_SIZE } from "~/consts";
-import { formatDateRange } from "~/lib/utils";
-
-export function HackathonShowcase({
- hackathons,
-}: {
- hackathons: Awaited
>;
-}) {
- hackathons.sort(
- (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
- );
-
- const mostRecent = hackathons[0];
-
- if (!mostRecent) {
- return (
-
-
-
- Recent Hackathon Attended
-
-
-
-
- No hackathons found
-
-
-
- );
- }
-
- return (
-
-
-
- Recent Hackathon Attended
-
-
-
-
- {mostRecent.name}
-
-
- {mostRecent.theme}
-
-
-
-
-
-
- {formatDateRange(mostRecent.startDate, mostRecent.endDate)}
-
-
-
-
-
-
- View All
-
-
-
- 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 (
+
+
+
+
+
+
+
Status
+
+
+ {hackerStatus}
+
+ {hackerStatus === "Confirmed" && (
+
+ )}
+
+
+
+
+
+
+ {/* Confirm Button */}
+ {hackerStatus === "Accepted" && (
+
+ {loading ? (
+
+ ) : (
+ CONFIRM
+ )}
+
+ )}
+ {/* Confirm Dialog */}
+
+
+
+
+ Congratulations!
+
+
+
+
+
+ You've successfully confirmed for the hackathon. We're excited
+ to see you there!
+
+
+
+ setIsConfirmOpen(false)}>Close
+
+
+
+ {/* Withdraw Button */}
+ {hackerStatus === "Confirmed" && (
+
+
+
+ WITHDRAW
+
+
+
+
+
+ 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.");
+ }}
+ />
+
+
+
+ {
+ setIsOpen(false);
+ setConfirmationText("");
+ }}
+ >
+ Cancel
+
+
+ {loading ? (
+
+ ) : (
+ "Withdraw from this hackathon"
+ )}
+
+
+
+
+ )}
+
+
+ );
+}
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 (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ };
+ return (
+
+
+
+
+
+
+ QR
+
+
+
+
+
+ Your QR Code
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
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
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
View Past Hackathons
+
+
+
+
+
+ Past Hackathons Attended
+
+
+ {hackathons.map((hackathon) => (
+
+ {/* Transparent Triangle overlay */}
+
+
+
+
+
+
+
+
+
+ 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;