- View and manage attendee registrations for club events.
+ View and manage attendee check-ins for club meetings. Hackathon
+ applications live on each edition's dashboard.
{/* Decorative Corner Accent */}
diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
index 4d7bdc77..a2e76496 100644
--- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
@@ -6,6 +6,7 @@ import { usePortalContext } from "@/lib/use-portal-context";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
+import { decodeHackathonParam } from "@/lib/hackathon-slug";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { ScannerTab } from "@/components/admin/hackathons/ScannerTab";
import { AttendeesTab } from "@/components/admin/hackathons/AttendeesTab";
@@ -28,7 +29,10 @@ type Tab =
export default function AdminHackathonDashboard() {
const { status } = useSession();
const params = useParams();
- const hackathonId = params?.id as string;
+ const rawId = params?.id;
+ const hackathonId = decodeHackathonParam(
+ Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""),
+ );
const [activeTab, setActiveTab] = useState("attendees");
const { data: portalContext, isLoading: portalLoading } = usePortalContext();
@@ -219,6 +223,7 @@ export default function AdminHackathonDashboard() {
)}
{activeTab === "analytics" && (
diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx
index 7ecc0e06..bd3fc79b 100644
--- a/sites/mainweb/app/(portal)/admin/judging/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useEffect, useMemo } from "react";
+import React, { useState, useEffect, useMemo, useRef } from "react";
import { Zap } from "lucide-react";
import { useSession } from "next-auth/react";
import { trpc } from "@/lib/trpc";
@@ -13,6 +13,7 @@ import { JudgeMatrixView } from "@/components/admin/judging/JudgeMatrixView";
import { RankingsView } from "@/components/admin/judging/RankingsView";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { JudgeLiveBoard } from "@/components/admin/hackathons/JudgeLiveBoard";
+import { judgingPrepIsCurrent } from "@/lib/judging-prep";
export default function AdminResultsPage() {
const { data: session, status } = useSession();
@@ -68,37 +69,105 @@ export default function AdminResultsPage() {
message: string | null;
error: string | null;
}>({ busy: false, message: null, error: null });
+ // The edition whose assign call was refused. Rebuild anyway sends force
+ // for this id, not whatever is selected now — a late CONFLICT from A must
+ // not rebuild B.
+ const [assignConflictId, setAssignConflictId] = useState(
+ null,
+ );
+ const selectedHackathonRef = useRef(selectedHackathon);
+ selectedHackathonRef.current = selectedHackathon;
+ const prepGen = useRef(0);
const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation();
const assignJudges = trpc.judge.assignJudgesToProjects.useMutation();
+ const isAssignConflict = (error: unknown) =>
+ typeof error === "object" &&
+ error !== null &&
+ "data" in error &&
+ (error as { data?: { code?: string } }).data?.code === "CONFLICT";
+
+ const stillThisRun = (hackathonId: string, gen: number) =>
+ judgingPrepIsCurrent(
+ hackathonId,
+ gen,
+ selectedHackathonRef.current,
+ prepGen.current,
+ );
+
+ const finishPrepare = async (
+ hackathonId: string,
+ gen: number,
+ message: string,
+ ) => {
+ await utils.judge.getRankings.invalidate({ hackathonId });
+ if (!stillThisRun(hackathonId, gen)) return;
+ await refetchJudgingStatus();
+ if (!stillThisRun(hackathonId, gen)) return;
+ setAssignConflictId(null);
+ setPrepState({ busy: false, error: null, message });
+ };
+
const prepareJudging = async () => {
- if (!selectedHackathon) return;
+ const hackathonId = selectedHackathon;
+ if (!hackathonId) return;
+ const gen = ++prepGen.current;
+ setAssignConflictId(null);
setPrepState({ busy: true, message: null, error: null });
try {
const promoted = await promoteSubmissions.mutateAsync({
- hackathonId: selectedHackathon,
+ hackathonId,
});
const assigned = await assignJudges.mutateAsync({
- hackathonId: selectedHackathon,
- });
- await utils.judge.getRankings.invalidate({
- hackathonId: selectedHackathon,
+ hackathonId,
});
- await refetchJudgingStatus();
+ if (!stillThisRun(hackathonId, gen)) return;
const warning = promoted.queuesNeedRebuild
? " One or more new projects carry a track no active judge covers — fix the track, then run this again."
: "";
+ await finishPrepare(
+ hackathonId,
+ gen,
+ `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`,
+ );
+ } catch (e) {
+ if (!stillThisRun(hackathonId, gen)) return;
+ setAssignConflictId(isAssignConflict(e) ? hackathonId : null);
setPrepState({
busy: false,
- error: null,
- message: `Synced ${promoted.created} new submission(s) of ${promoted.total}, and built queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Print the table cards next.${warning}`,
+ message: null,
+ error: e instanceof Error ? e.message : "Could not prepare judging.",
});
+ }
+ };
+
+ // The server names how many completed slots (or that judging is live) and
+ // asks for confirmation. This is the only control that sends force: true —
+ // /admin/setup used to, and now redirects here.
+ const rebuildQueuesAnyway = async () => {
+ const hackathonId = assignConflictId;
+ if (!hackathonId || hackathonId !== selectedHackathonRef.current) return;
+ const gen = ++prepGen.current;
+ setPrepState((s) => ({ ...s, busy: true }));
+ try {
+ const assigned = await assignJudges.mutateAsync({
+ hackathonId,
+ force: true,
+ });
+ if (!stillThisRun(hackathonId, gen)) return;
+ await finishPrepare(
+ hackathonId,
+ gen,
+ `Rebuilt queues for ${assigned.totalJudges} judge(s) covering ${assigned.coverage.min}-${assigned.coverage.max} projects each. Completed slots were kept.`,
+ );
} catch (e) {
+ if (!stillThisRun(hackathonId, gen)) return;
+ setAssignConflictId(isAssignConflict(e) ? hackathonId : null);
setPrepState({
busy: false,
message: null,
- error: e instanceof Error ? e.message : "Could not prepare judging.",
+ error: e instanceof Error ? e.message : "Could not rebuild queues.",
});
}
};
@@ -120,6 +189,12 @@ export default function AdminResultsPage() {
}
}, [hackathons, selectedHackathon]);
+ useEffect(() => {
+ prepGen.current += 1;
+ setAssignConflictId(null);
+ setPrepState({ busy: false, message: null, error: null });
+ }, [selectedHackathon]);
+
const categories = useMemo(() => {
if (!rankings?.rankings) return ["ALL"];
const cats = new Set(
@@ -338,12 +413,26 @@ export default function AdminResultsPage() {
)}
{prepState.error && (
-
- {prepState.error}
-
+
{prepState.error}
+ {assignConflictId === selectedHackathon && (
+
+ )}
+
)}
diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx
index 3a3f5ed0..a2b53518 100644
--- a/sites/mainweb/app/(portal)/admin/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/page.tsx
@@ -4,6 +4,7 @@ import { useSession } from "next-auth/react";
import { trpc } from "@/lib/trpc";
import { usePortalContext } from "@/lib/use-portal-context";
import { useState } from "react";
+import Link from "next/link";
import { QRCodeModal } from "@/components/portal/QRCodeModal";
import { EventFormModal } from "@/components/portal/EventFormModal";
import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal";
@@ -252,8 +253,9 @@ export default function AdminPage() {
Manager
- Create events, generate QR codes, and track attendance for general
- club gatherings.
+ Club meetings, workshops, and bootcamp sessions. These are not
+ part of a Hacklytics weekend — that itinerary lives on the
+ hackathon dashboard.
- Every submitted project becomes a judgeable entry with its own
- table number, carrying the tracks and challenges the team picked.
- Safe to run again as late submissions land — projects already
- synced are left alone.
-
- No submitted projects yet. Teams submit from /submit — come
- back once the deadline has passed.
-
-
- ) : (
-
-
-
- {promoteSubmissions.data.created} newly synced |{" "}
- {promoteSubmissions.data.alreadyPresent} already in
- judging | {promoteSubmissions.data.total} total
-
-
- {/* Queues are a snapshot, so promotion adds late projects to
- the queues that already exist — appending, because a
- rebuild reorders every queue mid-judging. */}
- {promoteSubmissions.data.queueRowsAdded > 0 && (
-
-
- Added to existing judge queues in{" "}
- {promoteSubmissions.data.queueRowsAdded} slot(s). No
- re-assign needed — nobody's current queue was reordered.
-
- Main track judges get 3-9 projects. Special label judges get all
- matching projects (randomized).
-
-
-
-
- Judges sign themselves up at{" "}
- /judge/register. Approve
- their applications under the Judges tab of this hackathon before
- assigning — only approved judges get a queue.
-
- {/* The server refuses a rebuild that would disturb live or
- completed judging, and reports how much is at stake. Only
- once the admin has read that number is overriding offered —
- they cannot know the count without the round trip. */}
- {assignJudges.error.data?.code === "CONFLICT" && (
-
- )}
-
- >
- );
+import { redirect } from "next/navigation";
+
+/**
+ * Judging Setup used to create a second hackathon record. Editions already
+ * exist on the hackathon side of the portal; queue prep lives on /admin/judging.
+ */
+export default function AdminSetupRedirect() {
+ redirect("/admin/judging");
}
diff --git a/sites/mainweb/app/(portal)/admin/staff/page.tsx b/sites/mainweb/app/(portal)/admin/staff/page.tsx
index b7f2f3eb..861fe487 100644
--- a/sites/mainweb/app/(portal)/admin/staff/page.tsx
+++ b/sites/mainweb/app/(portal)/admin/staff/page.tsx
@@ -20,7 +20,7 @@ const ROLES = [
{
id: "volunteer" as const,
label: "Volunteer",
- hint: "Badge scanning at /scan. Nothing else — the admin pages refuse them.",
+ hint: "Hackathon badge scanning at /scan and club pass scanning at /scan/club. Nothing else — the admin pages refuse them.",
},
{
id: "moderator" as const,
diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx
index d6243041..73617ba5 100644
--- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx
+++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx
@@ -8,6 +8,7 @@ import { trpc } from "@/lib/trpc";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { QRScannerModal } from "@/components/portal/QRScannerModal";
+import { decodeHackathonParam } from "@/lib/hackathon-slug";
type Project = {
id: string;
@@ -49,7 +50,10 @@ export default function JudgeHackathonPage() {
* procedures take a uuid, so a by-name URL would fail input validation.
* Resolve it first when it is not already an id.
*/
- const routeParam = params.id as string;
+ const rawId = params.id;
+ const routeParam = decodeHackathonParam(
+ Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""),
+ );
const isUuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
routeParam,
diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx
index 439e9fff..d2076a26 100644
--- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx
+++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx
@@ -15,6 +15,7 @@ import { ProjectsTab } from "@/components/hackathon/ProjectsTab";
import { ResultsTab } from "@/components/hackathon/ResultsTab";
import { TeamsTab } from "@/components/hackathon/TeamsTab";
import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable";
+import { decodeHackathonParam } from "@/lib/hackathon-slug";
function formatDate(d: Date | string) {
return new Date(d).toLocaleDateString("en-US", {
@@ -124,7 +125,10 @@ export default function HackathonDetailPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
- const hackathonId = params.id as string;
+ const rawId = params.id;
+ const hackathonId = decodeHackathonParam(
+ Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""),
+ );
const tabParam = searchParams.get("tab") as TabType | null;
const [tab, setTab] = useState(
@@ -192,7 +196,7 @@ export default function HackathonDetailPage() {
/>
- All Events
+ All Hackathons
{/* Header Card */}
diff --git a/sites/mainweb/app/(portal)/hacklytics/page.tsx b/sites/mainweb/app/(portal)/hacklytics/page.tsx
index 573e56cc..065f07f3 100644
--- a/sites/mainweb/app/(portal)/hacklytics/page.tsx
+++ b/sites/mainweb/app/(portal)/hacklytics/page.tsx
@@ -1,11 +1,10 @@
"use client";
-import { useEffect, useState } from "react";
import Link from "next/link";
-import { useSession } from "next-auth/react";
import { trpc } from "@/lib/trpc";
import { hackathonSlug } from "@/lib/hackathon-slug";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
+import { InterestForm } from "@/components/hackathon/InterestForm";
/**
* The public landing page for an edition that has been announced but is not yet
@@ -16,16 +15,12 @@ import { LoadingScreen } from "@/components/portal/LoadingScreen";
* page. A signed-out stranger is the entire audience, so everything above the
* form renders without a session and the sidebar is suppressed for it in
* PortalWrapper.
+ *
+ * The form writes against this edition's id (from getUpcoming), not a
+ * singleton. The same component is mounted on the edition page so joining
+ * from either place lands on the same row.
*/
-const EXPERIENCE_OPTIONS = [
- { value: "first", label: "This would be my first" },
- { value: "one_or_two", label: "I've done one or two" },
- { value: "three_plus", label: "I've done three or more" },
-] as const;
-
-type Experience = (typeof EXPERIENCE_OPTIONS)[number]["value"];
-
/**
* Dates are rendered from a fixed locale and an explicit time zone rather than
* the viewer's. The event happens in Atlanta; showing somebody in Singapore
@@ -58,85 +53,8 @@ const formatDeadline = (deadline: Date) =>
timeZone: "America/New_York",
});
-function Field({
- label,
- hint,
- children,
-}: {
- label: string;
- hint?: string;
- children: React.ReactNode;
-}) {
- return (
-
- );
-}
-
-const inputClass =
- "w-full px-4 py-3 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] text-sm rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-[var(--text-muted)]/50 transition-ui";
-
export default function HacklyticsPage() {
- const { data: session, status: sessionStatus } = useSession();
- const utils = trpc.useUtils();
-
const upcoming = trpc.hackathon.getUpcoming.useQuery();
- const hackathonId = upcoming.data?.id;
-
- const mine = trpc.hackathon.myInterest.useQuery(
- { hackathonId: hackathonId ?? "" },
- { enabled: !!hackathonId && !!session },
- );
-
- const [school, setSchool] = useState("");
- const [country, setCountry] = useState("");
- const [graduationYear, setGraduationYear] = useState("");
- const [experience, setExperience] = useState("");
- const [editing, setEditing] = useState(false);
- const [error, setError] = useState("");
-
- // Prefill from an existing entry so "edit" starts from what they told us,
- // rather than making them retype it to change one field.
- useEffect(() => {
- if (!mine.data) return;
- setSchool(mine.data.school ?? "");
- setCountry(mine.data.country ?? "");
- setGraduationYear(
- mine.data.graduationYear ? String(mine.data.graduationYear) : "",
- );
- setExperience((mine.data.experience as Experience) ?? "");
- }, [mine.data]);
-
- const refresh = async () => {
- if (hackathonId) await utils.hackathon.myInterest.invalidate({ hackathonId });
- };
-
- const join = trpc.hackathon.registerInterest.useMutation({
- onSuccess: async () => {
- setError("");
- setEditing(false);
- await refresh();
- },
- onError: (e) => setError(e.message),
- });
-
- const leave = trpc.hackathon.withdrawInterest.useMutation({
- onSuccess: async () => {
- setError("");
- setEditing(false);
- await refresh();
- },
- onError: (e) => setError(e.message),
- });
if (upcoming.isPending) return ;
@@ -182,9 +100,6 @@ export default function HacklyticsPage() {
// working past the moment registration opens — before, it collected the
// interest list; after, it points at the registration itself.
const registrationOpen = event.registrationOpen;
- const onList = !!mine.data;
- const showForm = !onList || editing;
- const busy = join.isPending || leave.isPending;
return (
@@ -259,174 +174,11 @@ export default function HacklyticsPage() {
Register now
- ) : sessionStatus === "loading" ? (
-
Checking sign-in…
- ) : !session ? (
-
-
- Get told the moment it opens
-
-
- Sign in so we have a verified address to reach you at. Google,
- GitHub, or a code sent to any email — no account needed
- beforehand, and it works wherever you are in the world.
-
-
- Sign in to join the list
-
-
) : (
-
-
-
- {onList ? "You're on the list" : "Join the interest list"}
-
-
- {onList
- ? `We'll email ${session.user?.email} the moment registration opens.`
- : "Four optional questions. They only shape how we plan the event — none of them affect whether you get in."}
-
-
-
- {showForm ? (
-
- ) : (
-
-
-
-
- )}
-
- {error && !showForm ? (
-
{error}
- ) : null}
-
+
)}
diff --git a/sites/mainweb/app/(portal)/scan/club/page.tsx b/sites/mainweb/app/(portal)/scan/club/page.tsx
new file mode 100644
index 00000000..a4728ee2
--- /dev/null
+++ b/sites/mainweb/app/(portal)/scan/club/page.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { useState } from "react";
+import { trpc } from "@/lib/trpc";
+import { usePortalContext } from "@/lib/use-portal-context";
+import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { ScanAccess } from "@/components/portal/ScanAccess";
+import { ClubScannerTab } from "@/components/portal/ClubScannerTab";
+
+/**
+ * Club meeting check-in.
+ *
+ * Scans a member pass, not a hackathon badge. Kept off /scan so club
+ * gatherings are not a mode of the hackathon desk.
+ */
+export default function ClubScanPage() {
+ const { data: portalContext } = usePortalContext();
+ const [clubEventId, setClubEventId] = useState("");
+
+ const { data: clubEvents } = trpc.events.list.useQuery(undefined, {
+ enabled: !!portalContext?.isScanner,
+ });
+
+ return (
+
+
+
+
+ Club
+
+
+ Club Check-In
+
+
+ Scan member passes into a club meeting. Not part of a hackathon
+ weekend.
+
+
+
+
+
+
+
+
+ {clubEventId ? (
+
+ ) : (
+
+
+ Choose a club event above to start scanning.
+
+
+ )}
+
+
+ );
+}
diff --git a/sites/mainweb/app/(portal)/scan/page.tsx b/sites/mainweb/app/(portal)/scan/page.tsx
index ac4cce48..e6d035cf 100644
--- a/sites/mainweb/app/(portal)/scan/page.tsx
+++ b/sites/mainweb/app/(portal)/scan/page.tsx
@@ -1,176 +1,76 @@
"use client";
import { useState } from "react";
-import { useSession } from "next-auth/react";
-import { useRouter } from "next/navigation";
-import Link from "next/link";
import { trpc } from "@/lib/trpc";
import { usePortalContext } from "@/lib/use-portal-context";
-import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { ScanAccess } from "@/components/portal/ScanAccess";
import { ScannerTab } from "@/components/admin/hackathons/ScannerTab";
-import { ClubScannerTab } from "@/components/portal/ClubScannerTab";
/**
- * The check-in desk.
+ * Hackathon badge check-in.
*
- * Deliberately outside the /admin segment. AdminLayout gates on
- * `portalContext.isAdmin`, which rejects volunteers by design — so the only
- * scanning UI in the product lived behind a door the scanner tier could not
- * open. The alternative was relaxing that gate and then re-scoping every admin
- * tab behind it, which is a much wider blast radius for the same outcome.
- *
- * Full staff can use this too: isScanner accepts any active admins row, so a
- * volunteer and an organiser get the same screen.
+ * Club meetings are a different desk (/scan/club). Volunteers are not admins,
+ * so this stays outside /admin; mixing both modes here is what put club
+ * events inside the hackathon.
*/
export default function ScanPage() {
- const { status } = useSession();
- const router = useRouter();
- const { data: portalContext, isLoading } = usePortalContext();
- const [mode, setMode] = useState<"hackathon" | "club">("hackathon");
+ const { data: portalContext } = usePortalContext();
const [hackathonId, setHackathonId] = useState("");
- const [clubEventId, setClubEventId] = useState("");
- // Public procedures, so a volunteer can read them. The listAll variants are
- // isAdmin and would reject exactly the people this page exists for.
const { data: hackathons } = trpc.hackathon.list.useQuery(
{},
{ enabled: !!portalContext?.isScanner },
);
- const { data: clubEvents } = trpc.events.list.useQuery(undefined, {
- enabled: !!portalContext?.isScanner,
- });
-
- if (status === "loading" || isLoading) {
- return ;
- }
- if (status === "unauthenticated") {
- router.push("/login");
- return null;
- }
-
- if (!portalContext?.isScanner) {
- return (
-
-
-
- Not a Scanner
+ return (
+
+
+
+
+ Hackathon
+
+
+ Check-In Desk
-
- This is the event check-in desk. Ask an organiser to add you as
- event staff.
+
+ Scan participant badges into this edition's workshops, meals,
+ and ceremonies.
-
- Back to Dashboard
-
-
-
- );
- }
+
- return (
-
-
-
- Check-In Desk
-
-
- Scan attendee badges into an event.
-
-
-
- {/* The two halves scan opposite things: a hackathon badge encodes the
- participant, a club pass encodes the member. */}
-
- {(["hackathon", "club"] as const).map((m) => (
-
- {clubEventId ? (
-
- ) : (
-
-
- Choose a club event above to start scanning.
-
-
- )}
- >
- )}
-
+ {hackathonId ? (
+
+ ) : (
+
+
+ Choose a hackathon above to start scanning.
+
+
+ )}
+
+
);
}
diff --git a/sites/mainweb/app/events/page.tsx b/sites/mainweb/app/events/page.tsx
index d1f2e5ad..459a333e 100644
--- a/sites/mainweb/app/events/page.tsx
+++ b/sites/mainweb/app/events/page.tsx
@@ -3,6 +3,7 @@ import Footer from "@/components/Footer";
import Section from "@/components/Section";
import { db, events } from "@query/db";
import { gte } from "drizzle-orm";
+import Link from "next/link";
/**
* The club's upcoming events.
@@ -64,18 +65,19 @@ export default async function EventsPage() {
- Events
+ Club Events
- Track upcoming hackathons, workshops, and community gatherings.
+ Upcoming DSGT meetings, workshops, and community gatherings.
+ Hacklytics has its own page.
- Upcoming Events
+ Upcoming Club Events
{upcoming.length === 0 ? (
- No upcoming events scheduled. Check back soon!
+ No upcoming club events scheduled. Check back soon!
) : (
@@ -109,6 +111,16 @@ export default async function EventsPage() {
)}
+
+ Looking for Hacklytics?{" "}
+
+ Go to the hackathon page
+
+ .
+
diff --git a/sites/mainweb/app/team/page.tsx b/sites/mainweb/app/team/page.tsx
index d81beb16..7e9c9c2e 100644
--- a/sites/mainweb/app/team/page.tsx
+++ b/sites/mainweb/app/team/page.tsx
@@ -6,19 +6,9 @@ import Navbar from "@/components/Navbar";
import Section from "@/components/Section";
import TeamCard from "@/components/TeamCard";
-// Asset imports
-import President from "@/assets/images/2025/aditi.jpg";
-import ViceP from "@/assets/images/2025/nitika.jpg";
-import Logistics1 from "@/assets/images/2025/alysha.jpg";
-import Logistics2 from "@/assets/images/2025/diya.jpeg";
-import Events from "@/assets/images/2025/aryan.jpeg";
-import Marketing from "@/assets/images/2025/smera.png";
-import Tech from "@/assets/images/2025/aamogh.png";
-import Content1 from "@/assets/images/2025/anushka.jpg";
-import Content2 from "@/assets/images/2025/glenne.png";
-
-import External2 from "@/assets/images/2025/vidhi.jpeg";
-import Project from "@/assets/images/2025/anika.jpg";
+// Asset imports — reuse in-repo photos only
+import Aamogh from "@/assets/images/2025/aamogh.png";
+import Diya from "@/assets/images/2025/diya.jpeg";
import Advisor from "@/assets/images/2025/jake.png";
import IDEaS from "@/assets/images/2025/ideas.png";
@@ -50,7 +40,7 @@ const Team = () => {
{/* Simplified Header Section */}
Meet the{" "}
@@ -79,109 +69,87 @@ const Team = () => {
- Aditi oversees all operations of DSGT, leading the executive board
- and coordinating with faculty and industry partners to shape the
- future of data science at GT.
+ Aamogh oversees DSGT operations, leading the executive board and
+ coordinating with faculty and industry partners to shape data
+ science at Georgia Tech.
- Nikita leads Hacklytics, DSGT's flagship
- datathon. She manages corporate sponsorships, event organization,
- and networking initiatives.
+ Diya serves as Vice President and Co-Director of{" "}
+ Hacklytics, DSGT's flagship datathon, managing
+ event organization, corporate sponsorships, and member-facing
+ programming.
-
- Alysha coordinates logistics for club events and Hacklytics,
- managing smooth operations and collaborating with external
- sponsors.
+
+ Nitya directs social media, graphic design, and outreach
+ strategies to increase engagement with DSGT both on and off
+ campus.
-
- Diya focuses on event management, Hacklytics logistic
- coordination, and logistics operations, ensuring a seamless
- experience for members and corporate partners.
+
+ Samantha oversees project logistics, managing the project portal
+ and setting up research opportunities with professors and
+ industry pros.
- Aamogh leads the Tech Team, managing frontend and backend systems
- for DSGT's digital infrastructure, including the Membership
- Portal.
+ Aishi develops strategic partnerships and manages sponsor
+ communications to support club initiatives and industry
+ collaboration.
- Smera directs social media, graphic design, and outreach
- strategies to increase engagement with DSGT both on and off
- campus.
+ Vishal manages sponsor communications and builds industry
+ partnerships that support DSGT initiatives.
-
- Aryan manages room bookings, catering, and volunteer coordination,
- ensuring technical workshops and socials execute perfectly.
+
+ Minjee coordinates logistics for club events and Hacklytics,
+ managing smooth operations and collaborating with external
+ sponsors.
- Vidhi develops strategic partnerships and manages sponsor
- communications to support club initiatives and industry
- collaboration.
+ Francisco manages event logistics so club programs and Hacklytics
+ run on schedule.
-
- Anushka leads the Content team in managing Bootcamp and Udemy
+
+ Yashika manages room bookings, catering, and volunteer
+ coordination, ensuring technical workshops and socials execute
+ perfectly.
+
+
+
+ Sahith leads the Content team in managing Bootcamp and Udemy
courses, helping members build professional ML projects.
-
- Glenne oversees Bootcamp workshops, ensuring members learn core
+
+ Victor oversees Bootcamp workshops, ensuring members learn core
data science skills and complete polished, industry-ready
projects.
-
- Anika oversees project logistics, managing the project portal and
- setting up research opportunities with professors and industry
- pros.
-
-
Jacob is an Assistant Professor in the School of Computer Science
and serves as a faculty advisor to DS@GT. His research focuses on
diff --git a/sites/mainweb/components/TeamCard/index.tsx b/sites/mainweb/components/TeamCard/index.tsx
index 268d560e..c6756bcd 100644
--- a/sites/mainweb/components/TeamCard/index.tsx
+++ b/sites/mainweb/components/TeamCard/index.tsx
@@ -5,17 +5,29 @@ import type { StaticImageData } from "next/image";
import Image from "next/image";
interface TeamCardProps extends HTMLAttributes {
- img: string | StaticImageData;
+ img?: string | StaticImageData;
name: string;
title: string;
+ href?: string;
zoom?: boolean;
children?: ReactNode;
}
+function initialsFor(name: string) {
+ return name
+ .split(/\s+/)
+ .filter((part) => /^[A-Za-z]/.test(part))
+ .slice(0, 2)
+ .map((part) => part[0])
+ .join("")
+ .toUpperCase();
+}
+
export default function TeamCard({
img,
name,
title,
+ href,
zoom,
children,
...rest
@@ -36,14 +48,23 @@ export default function TeamCard({
height: 140,
}}
>
-
+ {img ? (
+
+ ) : (
+
{/* Description */}
diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx
index 5d56c426..a8143327 100644
--- a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx
@@ -61,7 +61,7 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) {
const [sending, setSending] = useState(false);
const [progress, setProgress] = useState(null);
const [error, setError] = useState(null);
- const [showInterest, setShowInterest] = useState(false);
+ const [showInterest, setShowInterest] = useState(true);
// The four questions the public form collects were shown to no organiser at
// all — the data was gathered and then only ever read as a recipient count.
diff --git a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
index 197cb171..ec25f2aa 100644
--- a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx
@@ -40,13 +40,16 @@ import { AcceptanceWaves } from "./AcceptanceWaves";
import { AttendeeStats } from "./AttendeeStats";
import { statusColors, statusIcon } from "./attendee-status";
import type { RegistrationStatus } from "./attendee-status";
+import type { HackathonStatus } from "./constants";
export function AttendeesTab({
hackathonId,
hackathonName,
+ status,
}: {
hackathonId: string;
hackathonName: string;
+ status: HackathonStatus;
}) {
const utils = trpc.useUtils();
@@ -79,6 +82,18 @@ export function AttendeesTab({
// Stat tiles come from the aggregate, not from counting the rows on screen.
// Counting a page would report "12 pending" when 400 are.
const { data: analytics } = trpc.hackathon.analytics.useQuery({ hackathonId });
+ // Parent already fetched getById with the route slug; this UUID query
+ // misses that cache. Until it returns, fall back to the status the
+ // dashboard already has so announced editions don't flash the empty queue.
+ const { data: hackathon } = trpc.hackathon.getById.useQuery({
+ id: hackathonId,
+ });
+ const announced = (hackathon?.status ?? status) === "announced";
+ const { data: interestRows, isLoading: interestLoading } =
+ trpc.hackathon.listInterest.useQuery(
+ { hackathonId },
+ { enabled: announced },
+ );
const attendees = data?.attendees;
@@ -391,8 +406,65 @@ export function AttendeesTab({
return (
-
+
+ {announced ? (
+
+
+ Interest list
+
+
+ People who asked to be told when{" "}
+ {hackathonName}{" "}
+ opens. This is not an application queue — opening registration lets
+ them apply.
+
+ {interestLoading ? (
+
+ Loading this edition's list…
+
+ ) : (interestRows?.length ?? 0) === 0 ? (
+
+ Nobody has joined this edition yet. The public form writes here.
+
+ ) : (
+
+
+
+
+
Name
+
Email
+
School
+
Country
+
Grad
+
Experience
+
+
+
+ {interestRows?.map((row) => (
+
+
+ {row.name ?? "—"}
+
+
{row.email}
+
{row.school ?? "—"}
+
{row.country ?? "—"}
+
+ {row.graduationYear ?? "—"}
+
+
{row.experience ?? "—"}
+
+ ))}
+
+
+
+ )}
+
+ ) : (
+ <>
)}
+ >
+ )}
);
}
diff --git a/sites/mainweb/components/admin/hackathons/EventsTab.tsx b/sites/mainweb/components/admin/hackathons/EventsTab.tsx
index 134bfd1d..e009818a 100644
--- a/sites/mainweb/components/admin/hackathons/EventsTab.tsx
+++ b/sites/mainweb/components/admin/hackathons/EventsTab.tsx
@@ -1,6 +1,7 @@
"use client";
import React, { useState } from "react";
+import Link from "next/link";
import { trpc } from "@/lib/trpc";
import { trpcErrorMessage } from "@/lib/trpc-error";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
@@ -184,11 +185,16 @@ export function EventsTab({ hackathonId }: { hackathonId: string }) {
- Events
+ Weekend Itinerary
- {events?.length || 0} event{events?.length !== 1 ? "s" : ""}{" "}
- scheduled
+ {events?.length || 0} session
+ {events?.length !== 1 ? "s" : ""} this edition — workshops, meals,
+ ceremonies.{" "}
+
+ Club meetings are on Club Hub
+
+ .
- No events yet
+ No itinerary yet
- Create workshops, meals, ceremonies and more.
+ Add workshops, meals, and ceremonies for this weekend. Club
+ meetings belong on Club Hub, not here.
) : (
diff --git a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx
index 8c74e81d..1ef9ef01 100644
--- a/sites/mainweb/components/admin/hackathons/HackathonCard.tsx
+++ b/sites/mainweb/components/admin/hackathons/HackathonCard.tsx
@@ -4,6 +4,7 @@ import React from "react";
import Link from "next/link";
import { trpc } from "@/lib/trpc";
import { LiquidGlass } from "@/components/portal/LiquidGlass";
+import { adminHackathonPath } from "@/lib/hackathon-slug";
import type { HackathonStatus } from "@/components/admin/hackathons/constants";
export function HackathonCard({
@@ -30,6 +31,10 @@ export function HackathonCard({
const { data: events, isLoading: eventsLoading } =
trpc.hackathon.getEvents.useQuery({ hackathonId: hackathon.id });
+ const { data: interestCount } = trpc.hackathon.interestCount.useQuery(
+ { hackathonId: hackathon.id },
+ { enabled: hackathon.status === "announced" },
+ );
const updateMutation = trpc.hackathon.update.useMutation({
onSuccess: () => {
@@ -54,7 +59,7 @@ export function HackathonCard({
{hackathon.name}
@@ -89,9 +94,12 @@ export function HackathonCard({
@@ -148,8 +159,8 @@ export function HackathonCard({
) : !events || events.length === 0 ? (
- No events scheduled for this hackathon yet. Click Dashboard to
- add some!
+ No weekend itinerary yet. Open the dashboard to add workshops
+ and meals for this edition — club meetings stay on Club Hub.
+ {interestCount === undefined
+ ? "Checking this edition's list…"
+ : interestCount === 0
+ ? "Nobody has joined yet. The public form writes to this hackathon."
+ : `${interestCount} ${interestCount === 1 ? "person is" : "people are"} on this edition's list. Applications stay empty until you open registration. The Email tab has the roster.`}
+
- setHackathonName(e.target.value)}
- className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors"
- />
- setHackathonTracks(e.target.value)}
- className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors"
- />
- setHackathonChallenges(e.target.value)}
- className="w-full px-5 py-4 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none text-[var(--text-primary)] font-mono placeholder:text-gray-600 focus:outline-none focus:border-accent/40 transition-colors"
- />
- {/* Real dates, not "now" and "now + 24h".
- createHackathon used to stamp both from the moment the button was
- pressed, and the whole submission window is derived from the hacking
- start time — so every deadline the product enforces was anchored to
- an accident. */}
-
- );
-}
diff --git a/sites/mainweb/components/hackathon/InfoTab.tsx b/sites/mainweb/components/hackathon/InfoTab.tsx
index 889b96d8..e52a7cb7 100644
--- a/sites/mainweb/components/hackathon/InfoTab.tsx
+++ b/sites/mainweb/components/hackathon/InfoTab.tsx
@@ -1,7 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
-import Link from "next/link";
import { trpc } from "@/lib/trpc";
import {
formatPhoneAsTyped,
@@ -30,6 +29,8 @@ import {
MAJORS,
} from "@/components/hackathon/constants";
import type { ShirtSize, LevelOfStudy } from "@/components/hackathon/constants";
+import { InterestForm } from "@/components/hackathon/InterestForm";
+import { hackathonSlug } from "@/lib/hackathon-slug";
type RegistrationStep = 0 | 1 | 2 | 3;
@@ -464,22 +465,13 @@ export function InfoTab({
) : hackathon.status === "announced" ? (
- /* Announced is not closed — the lock below reads as "you missed it". */
-
-
- Registration Opens Soon
-
-
- Applications aren't open yet. Join the interest list and
- we'll email you the moment they are.
-
-
- Join the Interest List
-
-
+ /* Announced is not closed — the lock below reads as "you missed it".
+ Join against THIS edition's id, not a bounce to /hacklytics which
+ follows whichever event getUpcoming picks. */
+
) : isFull ? (