diff --git a/src/components/features/evaluator/response.tsx b/src/components/features/evaluator/response.tsx index 110d2d0..b1ca670 100644 --- a/src/components/features/evaluator/response.tsx +++ b/src/components/features/evaluator/response.tsx @@ -44,10 +44,12 @@ export function Response({ label, type, response, userId }: ResponseProps) { ) : type === "booleanMap" ? ( } /> + ) : type === "resume" ? ( + ) : loading ? ( ) : ( - + )} ); @@ -64,31 +66,64 @@ const BooleanMap = ({ value }: { value?: Record }) => { ); }; +const ResumeField = ({ href, loading }: { href?: string; loading: boolean }) => { + if (loading) return ; + + const value = href ?? ""; + if (!value.trim()) { + return ; + } + + const handleClipboard = () => { + navigator.clipboard.writeText(value); + toast("Copied link to clipboard!"); + }; + + return ( +
+ Uploaded + + View resume + + + +
+ ); +}; + const LinkField = ({ href }: { href?: string }) => { + const value = href ?? ""; + const isUrl = /^https?:\/\//i.test(value.trim()); + const handleClipboard = () => { - if (!href) return; - navigator.clipboard.writeText(href); + if (!value) return; + navigator.clipboard.writeText(value); toast("Copied link to clipboard!"); }; - const hasLink = Boolean(href); + if (!isUrl) { + return ; + } return ( diff --git a/src/components/features/hackerapp/hacker-app-question.tsx b/src/components/features/hackerapp/hacker-app-question.tsx index 62c5295..fbe1dd5 100644 --- a/src/components/features/hackerapp/hacker-app-question.tsx +++ b/src/components/features/hackerapp/hacker-app-question.tsx @@ -31,7 +31,11 @@ import { getEligibleSources, } from "./hacker-app-conditions"; import type { UsedFieldsRegistry } from "./hacker-app-main"; -import { LEGAL_NAME_FORM_INPUTS, isFormInputAllowedInSection } from "./hacker-app-sections"; +import { + LEGAL_NAME_FORM_INPUTS, + isFormInputAllowedInSection, + isQuestionTypeAllowedInSection, +} from "./hacker-app-sections"; const QUESTION_TYPES: HackerApplicationQuestionType[] = [ "Long Answer", @@ -44,6 +48,10 @@ const QUESTION_TYPES: HackerApplicationQuestionType[] = [ "School", "Major", "Country", + "Github", + "LinkedIn", + "Portfolio Website", + "Resume", ]; // Question types that if selected, bricks the select field and locks in the value @@ -53,6 +61,10 @@ const QUESTION_TYPES_UNIQUE: HackerApplicationQuestionType[] = [ "School", "Major", "Country", + "Github", + "LinkedIn", + "Portfolio Website", + "Resume", ]; export const SHOW_FORM_INPUT: HackerApplicationQuestionType[] = [ @@ -132,6 +144,7 @@ export const HackerAppQuestion = memo(function HackerAppQuestion({ ); const usableQuestionTypes = QUESTION_TYPES?.filter( (qt) => + isQuestionTypeAllowedInSection(qt, section) && (qt !== "Full Legal Name" || !hasSplitLegalName) && (!QUESTION_TYPES_UNIQUE.includes(qt) || !usedFieldsRegistry.questionType.has(qt)), ); diff --git a/src/components/features/hackerapp/hacker-app-sections.ts b/src/components/features/hackerapp/hacker-app-sections.ts index c1e9ab4..dfa5f9b 100644 --- a/src/components/features/hackerapp/hacker-app-sections.ts +++ b/src/components/features/hackerapp/hacker-app-sections.ts @@ -1,5 +1,6 @@ import type { HackerApplicationQuestionFormInputField, + HackerApplicationQuestionType, HackerApplicationSections, } from "@/lib/firebase/types"; @@ -69,3 +70,21 @@ export const isFormInputAllowedInSection = ( const pinned = SECTION_BY_FORM_INPUT[formInput]; return pinned === undefined || pinned === section; }; + +export const QUESTION_TYPE_SECTION: Partial< + Record +> = { + Portfolio: "Skills", + Github: "Skills", + LinkedIn: "Skills", + "Portfolio Website": "Skills", + Resume: "Skills", +}; + +export const isQuestionTypeAllowedInSection = ( + questionType: HackerApplicationQuestionType, + section: HackerApplicationSections, +): boolean => { + const pinned = QUESTION_TYPE_SECTION[questionType]; + return pinned === undefined || pinned === section; +}; diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 15919a4..d56f1ff 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -5,6 +5,7 @@ import { BadgeCheck, Bot, Calendar, + CalendarCheck, DollarSign, FileText, HelpCircle, @@ -112,6 +113,11 @@ export const generateHackathonNavigation = (hackathons: Hackathon[]) => { href: `/hackathons/${hackathon._id}/schedule`, icon: Calendar, }, + { + label: "Attendance", + href: `/hackathons/${hackathon._id}/attendance`, + icon: CalendarCheck, + }, { label: "Rewards", href: `/hackathons/${hackathon._id}/rewards`, diff --git a/src/lib/firebase/types.ts b/src/lib/firebase/types.ts index a3a81ae..e597c00 100644 --- a/src/lib/firebase/types.ts +++ b/src/lib/firebase/types.ts @@ -100,6 +100,14 @@ export interface HackathonDayOf { lastModifiedBy?: string; } +export interface PreHackathonWorkshop { + _id?: string; + eventId?: string; + name?: string; + lastModified?: Timestamp; + lastModifiedBy?: string; +} + /** * Sub-collection: /Hackathon/[hackathon]/Projects * @@ -350,7 +358,11 @@ export type HackerApplicationQuestionType = | "Dropdown" | "School" | "Major" - | "Country"; + | "Country" + | "Github" + | "LinkedIn" + | "Portfolio Website" + | "Resume"; // keep in sync with portal-v2 src/lib/firebase/types/hacker-app-questions.ts, which renders these export const FORM_INPUT_FIELDS = [ "academicYear", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3130806..90385a6 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as AuthStampbookIndexRouteImport } from './routes/_auth/stampbook import { Route as AuthStampbookRaffleRouteImport } from './routes/_auth/stampbook/raffle' import { Route as AuthHackathonsHackathonIdIndexRouteImport } from './routes/_auth/hackathons/$hackathonId/index' import { Route as AuthHackathonsHackathonIdApplicationRouteImport } from './routes/_auth/hackathons/$hackathonId/application' +import { Route as AuthHackathonsHackathonIdAttendanceRouteImport } from './routes/_auth/hackathons/$hackathonId/attendance' import { Route as AuthHackathonsHackathonIdRewardsRouteImport } from './routes/_auth/hackathons/$hackathonId/rewards' import { Route as AuthHackathonsHackathonIdScheduleRouteImport } from './routes/_auth/hackathons/$hackathonId/schedule' import { Route as AuthHackathonsHackathonIdSettingsRouteImport } from './routes/_auth/hackathons/$hackathonId/settings' @@ -94,6 +95,12 @@ const AuthHackathonsHackathonIdApplicationRoute = path: '/application', getParentRoute: () => AuthHackathonsHackathonIdRouteRoute, } as any) +const AuthHackathonsHackathonIdAttendanceRoute = + AuthHackathonsHackathonIdAttendanceRouteImport.update({ + id: '/attendance', + path: '/attendance', + getParentRoute: () => AuthHackathonsHackathonIdRouteRoute, + } as any) const AuthHackathonsHackathonIdRewardsRoute = AuthHackathonsHackathonIdRewardsRouteImport.update({ id: '/rewards', @@ -131,6 +138,7 @@ export interface FileRoutesByFullPath { '/stampbook/raffle': typeof AuthStampbookRaffleRoute '/stampbook/': typeof AuthStampbookIndexRoute '/hackathons/$hackathonId/application': typeof AuthHackathonsHackathonIdApplicationRoute + '/hackathons/$hackathonId/attendance': typeof AuthHackathonsHackathonIdAttendanceRoute '/hackathons/$hackathonId/rewards': typeof AuthHackathonsHackathonIdRewardsRoute '/hackathons/$hackathonId/schedule': typeof AuthHackathonsHackathonIdScheduleRoute '/hackathons/$hackathonId/settings': typeof AuthHackathonsHackathonIdSettingsRoute @@ -148,6 +156,7 @@ export interface FileRoutesByTo { '/stampbook/raffle': typeof AuthStampbookRaffleRoute '/stampbook': typeof AuthStampbookIndexRoute '/hackathons/$hackathonId/application': typeof AuthHackathonsHackathonIdApplicationRoute + '/hackathons/$hackathonId/attendance': typeof AuthHackathonsHackathonIdAttendanceRoute '/hackathons/$hackathonId/rewards': typeof AuthHackathonsHackathonIdRewardsRoute '/hackathons/$hackathonId/schedule': typeof AuthHackathonsHackathonIdScheduleRoute '/hackathons/$hackathonId/settings': typeof AuthHackathonsHackathonIdSettingsRoute @@ -168,6 +177,7 @@ export interface FileRoutesById { '/_auth/stampbook/raffle': typeof AuthStampbookRaffleRoute '/_auth/stampbook/': typeof AuthStampbookIndexRoute '/_auth/hackathons/$hackathonId/application': typeof AuthHackathonsHackathonIdApplicationRoute + '/_auth/hackathons/$hackathonId/attendance': typeof AuthHackathonsHackathonIdAttendanceRoute '/_auth/hackathons/$hackathonId/rewards': typeof AuthHackathonsHackathonIdRewardsRoute '/_auth/hackathons/$hackathonId/schedule': typeof AuthHackathonsHackathonIdScheduleRoute '/_auth/hackathons/$hackathonId/settings': typeof AuthHackathonsHackathonIdSettingsRoute @@ -188,6 +198,7 @@ export interface FileRouteTypes { | '/stampbook/raffle' | '/stampbook/' | '/hackathons/$hackathonId/application' + | '/hackathons/$hackathonId/attendance' | '/hackathons/$hackathonId/rewards' | '/hackathons/$hackathonId/schedule' | '/hackathons/$hackathonId/settings' @@ -205,6 +216,7 @@ export interface FileRouteTypes { | '/stampbook/raffle' | '/stampbook' | '/hackathons/$hackathonId/application' + | '/hackathons/$hackathonId/attendance' | '/hackathons/$hackathonId/rewards' | '/hackathons/$hackathonId/schedule' | '/hackathons/$hackathonId/settings' @@ -224,6 +236,7 @@ export interface FileRouteTypes { | '/_auth/stampbook/raffle' | '/_auth/stampbook/' | '/_auth/hackathons/$hackathonId/application' + | '/_auth/hackathons/$hackathonId/attendance' | '/_auth/hackathons/$hackathonId/rewards' | '/_auth/hackathons/$hackathonId/schedule' | '/_auth/hackathons/$hackathonId/settings' @@ -329,6 +342,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthHackathonsHackathonIdApplicationRouteImport parentRoute: typeof AuthHackathonsHackathonIdRouteRoute } + '/_auth/hackathons/$hackathonId/attendance': { + id: '/_auth/hackathons/$hackathonId/attendance' + path: '/attendance' + fullPath: '/hackathons/$hackathonId/attendance' + preLoaderRoute: typeof AuthHackathonsHackathonIdAttendanceRouteImport + parentRoute: typeof AuthHackathonsHackathonIdRouteRoute + } '/_auth/hackathons/$hackathonId/rewards': { id: '/_auth/hackathons/$hackathonId/rewards' path: '/rewards' @@ -362,6 +382,7 @@ declare module '@tanstack/react-router' { interface AuthHackathonsHackathonIdRouteRouteChildren { AuthHackathonsHackathonIdApplicationRoute: typeof AuthHackathonsHackathonIdApplicationRoute + AuthHackathonsHackathonIdAttendanceRoute: typeof AuthHackathonsHackathonIdAttendanceRoute AuthHackathonsHackathonIdRewardsRoute: typeof AuthHackathonsHackathonIdRewardsRoute AuthHackathonsHackathonIdScheduleRoute: typeof AuthHackathonsHackathonIdScheduleRoute AuthHackathonsHackathonIdSettingsRoute: typeof AuthHackathonsHackathonIdSettingsRoute @@ -373,6 +394,8 @@ const AuthHackathonsHackathonIdRouteRouteChildren: AuthHackathonsHackathonIdRout { AuthHackathonsHackathonIdApplicationRoute: AuthHackathonsHackathonIdApplicationRoute, + AuthHackathonsHackathonIdAttendanceRoute: + AuthHackathonsHackathonIdAttendanceRoute, AuthHackathonsHackathonIdRewardsRoute: AuthHackathonsHackathonIdRewardsRoute, AuthHackathonsHackathonIdScheduleRoute: diff --git a/src/routes/_auth/hackathons/$hackathonId/attendance.tsx b/src/routes/_auth/hackathons/$hackathonId/attendance.tsx new file mode 100644 index 0000000..db5c644 --- /dev/null +++ b/src/routes/_auth/hackathons/$hackathonId/attendance.tsx @@ -0,0 +1,210 @@ +import { PageHeader } from "@/components/graphy/typo"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import type { PreHackathonWorkshop } from "@/lib/firebase/types"; +import { useHackathon } from "@/providers/hackathon-provider"; +import { + buildPreHackathonQrUrl, + deletePreHackathonWorkshop, + subscribeToPreHackathonWorkshops, + upsertPreHackathonWorkshop, +} from "@/services/prehackathon"; +import { createFileRoute } from "@tanstack/react-router"; +import { Copy, Download, Loader2, Plus, Trash2 } from "lucide-react"; +import QRCode from "qrcode"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/_auth/hackathons/$hackathonId/attendance")({ + component: AttendanceComponent, +}); + +const toEventId = (name: string) => + name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +function WorkshopQrCard({ + workshop, + qrUrl, + onDelete, +}: { + workshop: PreHackathonWorkshop; + qrUrl: string; + onDelete: () => void; +}) { + const [dataUrl, setDataUrl] = useState(null); + + useEffect(() => { + let active = true; + QRCode.toDataURL(qrUrl, { width: 200, margin: 2 }) + .then((url) => { + if (active) setDataUrl(url); + }) + .catch(console.error); + return () => { + active = false; + }; + }, [qrUrl]); + + const copyLink = async () => { + await navigator.clipboard.writeText(qrUrl); + toast.success("Attendance link copied"); + }; + + const download = () => { + if (!dataUrl) return; + const link = document.createElement("a"); + link.href = dataUrl; + link.download = `attendance-${workshop.eventId}.png`; + link.click(); + }; + + return ( + + +
+ {dataUrl ? ( + {`QR + ) : ( + + )} +
+
+

{workshop.name}

+

{workshop.eventId}

+
+ + + +
+

+ Scanning this QR opens the portal where hackers sign in and get marked attended. +

+
+
+
+ ); +} + +function AttendanceComponent() { + const { activeHackathon } = useHackathon(); + const [workshops, setWorkshops] = useState([]); + const [name, setName] = useState(""); + + useEffect(() => { + if (!activeHackathon) return; + + const unsubWorkshops = subscribeToPreHackathonWorkshops(activeHackathon, setWorkshops); + + return () => { + unsubWorkshops(); + }; + }, [activeHackathon]); + + const addWorkshop = async () => { + const eventId = toEventId(name); + if (!eventId) { + toast.error("Workshop name can't be empty"); + return; + } + if (workshops.some((workshop) => workshop.eventId === eventId)) { + toast.error(`A workshop with slug "${eventId}" already exists`); + return; + } + try { + await upsertPreHackathonWorkshop(activeHackathon, eventId, name.trim()); + setName(""); + toast.success("Workshop added"); + } catch { + toast.error("Failed to add workshop"); + } + }; + + const removeWorkshop = async (workshop: PreHackathonWorkshop) => { + if (!workshop.eventId) return; + if ( + !window.confirm( + `Delete "${workshop.name}"? Hackers already marked attended keep their record.`, + ) + ) + return; + try { + await deletePreHackathonWorkshop(activeHackathon, workshop.eventId); + toast.success("Workshop deleted"); + } catch { + toast.error("Failed to delete workshop"); + } + }; + + return ( +
+ + Attendance + {activeHackathon} + + + + +

Pre-hackathon workshops

+

+ Runs before the event. Hackers check in by scanning the QR on the portal. +

+
+ +
+ setName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") addWorkshop(); + }} + placeholder="e.g. Intro to React" + /> + +
+ {workshops.length === 0 ? ( +

No pre-hackathon workshops yet.

+ ) : ( +
+ {workshops.map((workshop) => + workshop.eventId ? ( + removeWorkshop(workshop)} + /> + ) : null, + )} +
+ )} +
+
+
+ ); +} diff --git a/src/services/prehackathon.ts b/src/services/prehackathon.ts new file mode 100644 index 0000000..857cba7 --- /dev/null +++ b/src/services/prehackathon.ts @@ -0,0 +1,67 @@ +import { auth, db } from "@/lib/firebase/client"; +import type { PreHackathonWorkshop } from "@/lib/firebase/types"; +import { getHackathonType } from "@/lib/utils"; +import { + Timestamp, + collection, + deleteDoc, + doc, + onSnapshot, + query, + setDoc, +} from "firebase/firestore"; + +const PORTAL_BASE_URL = import.meta.env.VITE_QR_PORTAL_ORIGIN || "https://portal.nwplus.io"; + +export const subscribeToPreHackathonWorkshops = ( + hackathon: string, + callback: (docs: PreHackathonWorkshop[]) => void, +) => + onSnapshot( + query(collection(db, "Hackathons", hackathon, "PreHackathonWorkshops")), + (querySnapshot) => { + const workshops = []; + for (const workshop of querySnapshot.docs) { + workshops.push({ + ...(workshop.data() as unknown as PreHackathonWorkshop), + _id: workshop.id, + }); + } + callback(workshops); + }, + ); + +export const upsertPreHackathonWorkshop = async ( + hackathon: string, + eventId: string, + name: string, +) => { + try { + const record = { + lastModified: Timestamp.now(), + lastModifiedBy: auth.currentUser?.email ?? "", + }; + + await setDoc( + doc(db, "Hackathons", hackathon, "PreHackathonWorkshops", eventId), + { eventId, name, ...record }, + { merge: true }, + ); + } catch (error) { + console.error(error); + throw error; + } +}; + +export const deletePreHackathonWorkshop = async (hackathon: string, eventId: string) => { + if (!eventId) return; + try { + await deleteDoc(doc(db, "Hackathons", hackathon, "PreHackathonWorkshops", eventId)); + } catch (error) { + console.error(error); + throw error; + } +}; + +export const buildPreHackathonQrUrl = (hackathonId: string, eventId: string, name: string) => + `${PORTAL_BASE_URL}/${getHackathonType(hackathonId)}/attendance?event=${eventId}&name=${encodeURIComponent(name)}`;