diff --git a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts index 41b09226..30d1a979 100644 --- a/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts +++ b/packages/api/src/.internal-tests/hackathon-admin-edge.test.ts @@ -12,6 +12,8 @@ const mockUpdate = vi.fn(); const mockDelete = vi.fn(); // Row count returned by `select({ count })...from()`, keyed by table. const mockCount = vi.fn(); +/** maxParticipants as the capacity check reads it; undefined = uncapped. */ +const mockMaxParticipants = vi.fn<() => number | undefined>(() => undefined); // Acceptance emails are sent through a dynamic import; intercept it so we can // see exactly who a mass-approval actually mailed. const mockSendAcceptanceEmail = vi.fn(); @@ -101,7 +103,17 @@ vi.mock("@query/db", () => { }, }), select: vi.fn().mockImplementation(() => ({ - from: (tbl: any) => thenable([{ count: mockCount(tbl?._t) ?? 0 }]), + from: (tbl: any) => { + const count = mockCount(tbl?._t) ?? 0; + return thenable([ + { + count, + n: count, + max: + tbl?._t === "hackathons" ? mockMaxParticipants() : undefined, + }, + ]); + }, })), }, admins: { @@ -266,6 +278,7 @@ describe("Hackathon admin management edge cases", () => { vi.clearAllMocks(); cache.clear(); mockCount.mockReturnValue(0); + mockMaxParticipants.mockImplementation(() => undefined); mockFindMany.mockReturnValue([]); mockInsert.mockReturnValue([{}]); mockUpdate.mockReturnValue([{}]); @@ -345,6 +358,43 @@ describe("Hackathon admin management edge cases", () => { ).rejects.toThrow(/Admin access required/); }); + // maxParticipants caps acceptances. Two seats, both taken: accepting one + // more is refused whole rather than going over. + it("refuses to accept past capacity", async () => { + const caller = adminCaller({}, "admin"); + mockMaxParticipants.mockImplementation(() => 2); + const counts = [2, 1]; // seated, then newly accepted by this call + mockCount.mockImplementation((table: string) => + table === "hackathonParticipants" ? counts.shift() : 0, + ); + + await expect( + caller.hackathon.batchUpdateParticipantStatus({ + hackathonId: HACK_A, + participantIds: [PART_A1], + status: "approved", + }), + ).rejects.toThrow(/Only 0 seats left of 2/); + }); + + it("accepts while seats remain", async () => { + const caller = adminCaller({}, "admin"); + mockMaxParticipants.mockImplementation(() => 2); + const counts = [1, 1]; + mockCount.mockImplementation((table: string) => + table === "hackathonParticipants" ? counts.shift() : 0, + ); + mockUpdate.mockReturnValue([{ id: PART_A1, userId: "u1" }]); + + await expect( + caller.hackathon.batchUpdateParticipantStatus({ + hackathonId: HACK_A, + participantIds: [PART_A1], + status: "approved", + }), + ).resolves.toBeDefined(); + }); + it("lets a volunteer work a check-in desk", async () => { const caller = volunteerCaller({ hackathonEvents: { id: EVENT_A, hackathonId: HACK_A }, diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index d5b71732..8640c317 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -6,6 +6,8 @@ import { db } from "@query/db"; // Fully mock the DB at the file level. vi.mock factories are hoisted and // file-scoped, so this mirrors the shape used by routers.test.ts. const mockFindFirst = vi.fn(); +/** Rows a bare `await select().from().where()` resolves to. */ +const mockWhereRows = vi.fn<() => unknown[]>(() => [{ n: 1 }]); const mockFindMany = vi.fn(); const mockInsert = vi.fn(); const mockUpdate = vi.fn(); @@ -74,6 +76,9 @@ vi.mock("@query/db", () => { select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockImplementation(() => ({ where: vi.fn().mockImplementation(() => ({ + // Awaited directly: a count such as the judging queue check. + then: (ok: any, err: any) => + Promise.resolve(mockWhereRows()).then(ok, err), orderBy: vi.fn().mockResolvedValue([{ count: 0 }]), groupBy: vi.fn().mockResolvedValue([]), limit: vi.fn().mockResolvedValue([]), @@ -305,7 +310,9 @@ describe("Hackathon end-to-end flow", () => { ); }); - it("rejects registration when the hackathon is at capacity", async () => { + // Capacity caps acceptances, not applications: a full house still takes + // applications, and the seat is enforced when an organiser accepts. + it("takes an application when every seat is already taken", async () => { mockFindFirst.mockImplementation((table) => table === "hackathons" ? openHackathon({ maxParticipants: 500, currentParticipants: 500 }) @@ -313,9 +320,10 @@ describe("Hackathon end-to-end flow", () => { ); const caller = appRouter.createCaller(createMockCtx("user_a")); - await expect(caller.hackathon.register(registrationInput())).rejects.toThrow( - /full/, - ); + const outcome = await caller.hackathon + .register(registrationInput()) + .catch((error: unknown) => error); + expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/); }); }); @@ -541,6 +549,7 @@ describe("Hackathon end-to-end flow", () => { it("only lets an admin toggle judging on", async () => { const caller = adminCaller(); + mockWhereRows.mockReturnValueOnce([{ n: 12 }]); mockUpdate.mockReturnValue([{ judgingActive: true }]); const res = await caller.judge.toggleJudging({ @@ -550,6 +559,17 @@ describe("Hackathon end-to-end flow", () => { expect(res).toMatchObject({ success: true, judgingActive: true }); }); + // With nothing queued every judge opened straight onto "All done, 0 of 0". + it("refuses to open judging before any judge has a table", async () => { + const caller = adminCaller(); + mockWhereRows.mockReturnValueOnce([{ n: 0 }]); + + await expect( + caller.judge.toggleJudging({ hackathonId: HACK_A, active: true }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + it("refuses to let a non-admin toggle judging", async () => { mockFindFirst.mockImplementation(() => undefined); const caller = appRouter.createCaller(createMockCtx("random_user")); @@ -604,12 +624,14 @@ describe("Hackathon end-to-end flow", () => { maxMembers: 4 as const, }; - it("refuses team creation before the window opens at +12h", async () => { - const caller = teamCaller(2); + // Accepted hackers can find teammates before kickoff. + it("allows team creation before hacking starts", async () => { + const caller = teamCaller(-48); + mockInsert.mockReturnValue([{ id: "team_1", name: "meow" }]); - await expect(caller.team.createTeam(newTeam)).rejects.toThrow( - /not open yet/, - ); + await expect(caller.team.createTeam(newTeam)).resolves.toMatchObject({ + name: "meow", + }); }); it("allows team creation inside the window", async () => { @@ -649,18 +671,21 @@ describe("Hackathon end-to-end flow", () => { expect(res.canLeave).toBe(false); }); + // Teams form from acceptance, not from +12h: two hours in, an accepted + // hacker can already create, join and leave. it("reports the window so the UI can disable instead of failing", async () => { const caller = teamCaller(2); const res = await caller.team.window({ hackathonId: HACK_A }); expect(res).toMatchObject({ - isOpen: false, - canCreate: false, - canJoin: false, - canLeave: false, + isOpen: true, + canCreate: true, + canJoin: true, + canLeave: true, + opensAt: null, }); - expect(res.leaveLocksAt.getTime() - res.opensAt.getTime()).toBe( - 12 * HOUR, + expect(res.closesAt.getTime() - res.leaveLocksAt.getTime()).toBe( + 10 * HOUR, ); }); }); diff --git a/packages/api/src/.internal-tests/judge-edge.test.ts b/packages/api/src/.internal-tests/judge-edge.test.ts index 3e80d96c..1f8ce744 100644 --- a/packages/api/src/.internal-tests/judge-edge.test.ts +++ b/packages/api/src/.internal-tests/judge-edge.test.ts @@ -1264,22 +1264,23 @@ describe("Judge edge cases", () => { }); /** - * Activating and deactivating a person is the super-admin tier. A plain - * admin runs the event; deciding who holds a role does not come with that. + * Approving a judge is part of running the event, so any admin may; it + * was super-admin only while every admin saw the button. Deleting the + * judge, votes and all, stays super-admin. */ - it("refuses a plain admin, and writes nothing", async () => { + it("lets a plain admin approve a judge but not remove one", async () => { mockFindFirst.mockImplementation((table: string) => table === "admins" ? PLAIN_ADMIN_ROW : undefined, ); - await expect( - adminCaller().judge.setActive({ judgeId: JUDGE_ID, isActive: true }), - ).rejects.toMatchObject({ code: "FORBIDDEN" }); + const approval = await adminCaller() + .judge.setActive({ judgeId: JUDGE_ID, isActive: true }) + .catch((error: unknown) => error); + expect((approval as { code?: string })?.code).not.toBe("FORBIDDEN"); + await expect( adminCaller().judge.remove({ judgeId: JUDGE_ID }), ).rejects.toMatchObject({ code: "FORBIDDEN" }); - - expect(mockUpdate).not.toHaveBeenCalled(); expect(mockDelete).not.toHaveBeenCalled(); }); diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index 87f1b39b..a7299164 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -504,15 +504,11 @@ describe("Participant edge cases", () => { }, ); - it("lets a team form at exactly +12h but not one millisecond earlier", async () => { - const early = atOffset(12 * HOUR - 1); + // No opening time: the window is open from acceptance up to +34h. + it("lets a team form a day before hacking starts", async () => { + const early = atOffset(-24 * HOUR); await expect( early.team.createTeam({ hackathonId: HACK_A, name: "meow", maxMembers: 4 }), - ).rejects.toThrow(/not open yet/); - - const onTime = atOffset(12 * HOUR); - await expect( - onTime.team.createTeam({ hackathonId: HACK_A, name: "meow", maxMembers: 4 }), ).resolves.toMatchObject({ id: TEAM_A }); }); @@ -865,6 +861,62 @@ describe("Participant edge cases", () => { }); // ===================================================================== + // There was no way to take a registration back, so an accepted no-show + // kept a seat for good. Allowed only before you are part of the event. + describe("Withdrawing a registration", () => { + const wire = (participant: Record | undefined) => + mockFindFirst.mockImplementation((table: string) => + table === "hackathonParticipants" ? participant : undefined, + ); + + it("removes a pending applicant who has nothing attached", async () => { + wire({ id: PARTICIPANT_A, teamId: null, registrationStatus: "pending" }); + mockDelete.mockReturnValueOnce([{ id: PARTICIPANT_A }]); + + await expect( + callerFor("user_a").hackathon.withdrawRegistration({ + hackathonId: HACK_A, + }), + ).resolves.toEqual({ success: true }); + expect(deletedTables()).toContain(hackathonParticipants); + }); + + // A badge scan or team join between the read and the delete: the + // conditions carried into the DELETE match nothing, and nothing is lost. + it("refuses when the registration changed after it was read", async () => { + wire({ id: PARTICIPANT_A, teamId: null, registrationStatus: "approved" }); + mockDelete.mockReturnValueOnce([]); + + await expect( + callerFor("user_a").hackathon.withdrawRegistration({ + hackathonId: HACK_A, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it.each([ + [ + "a team member", + { id: PARTICIPANT_A, teamId: TEAM_A, registrationStatus: "approved" }, + /Leave your team first/, + ], + [ + "somebody already checked in", + { id: PARTICIPANT_A, teamId: null, registrationStatus: "checked_in" }, + /already checked in/, + ], + ])("refuses %s", async (_label, participant, message) => { + wire(participant); + + await expect( + callerFor("user_a").hackathon.withdrawRegistration({ + hackathonId: HACK_A, + }), + ).rejects.toThrow(message); + expect(mockDelete).not.toHaveBeenCalled(); + }); + }); + describe("6. Registration under contention", () => { // BUG: the duplicate guard at registration.ts:95-108 is an unlocked // findFirst; the hackathon_participant_hackathon_user_idx violation that follows is @@ -887,9 +939,10 @@ describe("Participant edge cases", () => { }); }); - // BUG: registration.ts:110-118 reads currentParticipants with a plain - // findFirst and increments at :173 — two racers both see 499/500. - it("admits only one of two racing registrations into the last seat", async () => { + // Applying no longer claims a seat — acceptance does (admin.ts + // assertSeats) — so two applicants racing for the last seat both get in, + // and the seat count is untouched. + it("lets racing applications through without taking a seat", async () => { const hackathonRow = runningHackathon(-24, { maxParticipants: 500, currentParticipants: 499, @@ -898,30 +951,16 @@ describe("Participant edge cases", () => { table === "hackathons" ? { ...hackathonRow } : undefined, ); mockInsert.mockReturnValue([{ id: PARTICIPANT_A }]); - mockUpdate.mockImplementation((_op, updateArgs) => { - // Stands in for sql`currentParticipants + 1`, including the rollback: - // the loser of the race claims a seat and then hands it straight back - // when the re-read shows it went over. - if (updateArgs[0] === hackathons) { - hackathonRow.currentParticipants += 1; - __onRollback(() => { - hackathonRow.currentParticipants -= 1; - }); - } - return []; - }); const results = await Promise.allSettled([ callerFor("user_a").hackathon.register(registrationInput()), callerFor("user_b").hackathon.register(registrationInput()), ]); - const rejected = results.filter((r) => r.status === "rejected"); - expect(rejected).toHaveLength(1); - expect(String((rejected[0] as PromiseRejectedResult)?.reason)).toMatch( - /full/, - ); - expect(hackathonRow.currentParticipants).toBe(500); + expect(results.every((r) => r.status === "fulfilled")).toBe(true); + expect( + mockUpdate.mock.calls.some((call) => call[1]?.[0] === hackathons), + ).toBe(false); }); // BUG: registration.ts:178 calls deletePattern("hackathon*") inside the @@ -955,6 +994,7 @@ describe("Participant edge cases", () => { }; return undefined; }); + mockUpdate.mockReturnValue([{ id: PARTICIPANT_A }]); await callerFor("admin_user_id").hackathon.updateParticipantStatus({ hackathonId: HACK_A, @@ -965,6 +1005,34 @@ describe("Participant edge cases", () => { expect(updatedTables()).toContain(hackathons); }); + it("refuses a check-in when a concurrent change unseated the participant", async () => { + mockFindFirst.mockImplementation((table) => { + if (table === "admins") + return { userId: "admin_user_id", isActive: true, role: "admin" }; + if (table === "hackathonParticipants") + return { + id: PARTICIPANT_A, + hackathonId: HACK_A, + registrationStatus: "approved", + }; + return undefined; + }); + // The seated-only WHERE matched nothing: someone waitlisted them between + // the read and the write. + mockUpdate.mockReturnValue([]); + + await expect( + callerFor("admin_user_id").hackathon.updateParticipantStatus({ + hackathonId: HACK_A, + participantId: PARTICIPANT_A, + status: "checked_in", + }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringContaining("changed just now"), + }); + }); + // BUG: registration.ts:222-239 is a publicProcedure whose column allow-list // includes registrationStatus, and the query has no limit. it("hides who was rejected from the public roster", async () => { diff --git a/packages/api/src/.internal-tests/qr-checkin.test.ts b/packages/api/src/.internal-tests/qr-checkin.test.ts index 32a0bb17..2bbb5c03 100644 --- a/packages/api/src/.internal-tests/qr-checkin.test.ts +++ b/packages/api/src/.internal-tests/qr-checkin.test.ts @@ -100,6 +100,7 @@ vi.mock("@query/db", async () => { hackathonParticipants: table("hackathonParticipants"), hackathonTeams: table("hackathonTeams"), hackathonProjects: table("hackathonProjects"), + judgingProjects: table("judgingProjects"), hackathonEvents: table("hackathonEvents"), hackathonEventAttendees: table("hackathonEventAttendees"), members: table("members"), @@ -171,6 +172,12 @@ vi.mock("@query/db", async () => { status: "status", submittedAt: "submitted_at", }, + judgingProjects: { + id: "id", + sourceProjectId: "source_project_id", + withdrawnAt: "withdrawn_at", + tableNumber: "table_number", + }, hackathonEvents: { id: "id", hackathonId: "hackathon_id", diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index c10cf73c..66416552 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -712,10 +712,33 @@ describe("Router Integration and Access Control Verification Suite", () => { ).rejects.toThrow(); }); + // Any uuid used to pass; a missing one hit the foreign key as a 500. + it.each([ + ["a missing hackathon", null], + ["a draft", { id: "h", isPublic: true, status: "draft" }], + ["a hidden edition", { id: "h", isPublic: false, status: "open" }], + ])("refuses a judge application to %s", async (_label, hackathon) => { + const ctx = createMockCtx("applicant_user_id"); + mockFindFirst.mockImplementation((table) => + table === "hackathons" ? hackathon : null, + ); + + await expect( + appRouter.createCaller(ctx).judge.register({ + hackathonId: "00000000-0000-4000-8000-000000000001", + name: "Ada Lovelace", + email: "ada@example.com", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + it("should prevent registered participants from applying to be a judge", async () => { const ctx = createMockCtx("participant_user_id"); const hackathonId = "00000000-0000-4000-8000-000000000001"; mockFindFirst.mockImplementation((table) => { + if (table === "hackathons") { + return { id: hackathonId, isPublic: true, status: "open" }; + } if (table === "hackathonParticipants") { return { id: "participant_1", @@ -942,7 +965,7 @@ describe("Router Integration and Access Control Verification Suite", () => { describe("8. Hackathon Participant Registration (does it add users to the hackathon)", () => { const hackathonId = "00000000-0000-4000-8000-000000000010"; - it("should successfully register a user for an open hackathon and increment participant count", async () => { + it("registers a user for an open hackathon without taking a seat", async () => { const ctx = createMockCtx("new_user_id"); mockFindFirst.mockImplementation((table) => { @@ -994,7 +1017,8 @@ describe("Router Integration and Access Control Verification Suite", () => { expect(res.id).toBe("participant_new"); expect(res.userId).toBe("new_user_id"); expect(mockInsert).toHaveBeenCalled(); - expect(mockUpdate).toHaveBeenCalled(); + // Capacity counts accepted people; applying leaves the count alone. + expect(mockUpdate).not.toHaveBeenCalled(); }); it("should reject registration if user is already registered", async () => { @@ -1029,7 +1053,7 @@ describe("Router Integration and Access Control Verification Suite", () => { ).rejects.toThrowError("You are already registered for this hackathon"); }); - it("should reject registration if hackathon capacity is full", async () => { + it("takes an application even when every seat is taken", async () => { const ctx = createMockCtx("user_id"); mockFindFirst.mockImplementation((table) => { @@ -1045,8 +1069,9 @@ describe("Router Integration and Access Control Verification Suite", () => { }); const caller = appRouter.createCaller(ctx); - await expect( - caller.hackathon.register({ + // Every seat taken still takes the application: capacity is enforced + // when an organiser accepts, so the full house can feed a waitlist. + const outcome = await caller.hackathon.register({ hackathonId, firstName: "Jane", lastName: "Smith", @@ -1059,8 +1084,9 @@ describe("Router Integration and Access Control Verification Suite", () => { country: "United States", whyAttend: "I want to build with data.", agreeToCodeOfConduct: true, - }), - ).rejects.toThrowError("This hackathon is full"); + }) + .catch((error: unknown) => error); + expect(String((outcome as Error)?.message ?? "")).not.toMatch(/full/); }); it("should reject registration if hackathon status is not open", async () => { diff --git a/packages/api/src/routers/hackathon/admin.ts b/packages/api/src/routers/hackathon/admin.ts index 65c19076..0955a4c8 100644 --- a/packages/api/src/routers/hackathon/admin.ts +++ b/packages/api/src/routers/hackathon/admin.ts @@ -8,6 +8,7 @@ import { createTRPCRouter } from "../../trpc"; import { isAdmin, isScanner } from "../../middleware/procedures"; import { isUniqueViolation } from "../../middleware/db-errors"; import { recordAdminAction } from "../../middleware/audit"; +import { CacheKeys } from "../../middleware/cache"; import { MASS_EMAIL_BATCH } from "../../services/email-limits"; import { hackathons, @@ -16,16 +17,22 @@ import { hackathonEventAttendees, users, } from "@query/db"; -import { eq, and, inArray, ne, sql } from "drizzle-orm"; +import { eq, and, inArray, ne, notInArray, sql } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; -// Re-derives currentParticipants from the rows that actually hold a seat. -// Rejected and waitlisted applicants do not, and registration.ts only ever -// increments, so a rejected applicant would consume capacity forever. The +// A seat belongs to somebody accepted. maxParticipants caps acceptances, not +// applications: applying is free, so an organiser can take more applications +// than seats and fill them in waves. +const SEATED_STATUSES = ["approved", "checked_in"] as const; + +// Re-derives currentParticipants from the rows that actually hold a seat. The // hackathon row is locked first: `SET x = (subquery)` plans the subquery once // per statement, so a register() committing mid-wait would be overwritten and // the seat total would drift low enough to admit people past maxParticipants. -const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) => +export const syncCurrentParticipants = ( + db: DrizzleDB, + hackathonId: string, +) => db.transaction(async (tx) => { await tx .select({ id: hackathons.id }) @@ -36,21 +43,73 @@ const syncCurrentParticipants = (db: DrizzleDB, hackathonId: string) => await tx .update(hackathons) .set({ - currentParticipants: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${hackathonId} and ${hackathonParticipants.registrationStatus} in ('pending', 'approved', 'checked_in'))`, + currentParticipants: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${hackathonId} and ${hackathonParticipants.registrationStatus} in ('approved', 'checked_in'))`, }) .where(eq(hackathons.id, hackathonId)); }); +/** + * Refuses an acceptance that would pass maxParticipants. Call inside the + * transaction that writes the new statuses: it locks the hackathon row, so two + * organisers accepting at once are counted one after the other. People already + * seated are not counted twice. + */ +async function assertSeats( + tx: DrizzleDB, + hackathonId: string, + participantIds: string[], +) { + const [hackathon] = await tx + .select({ max: hackathons.maxParticipants }) + .from(hackathons) + .where(eq(hackathons.id, hackathonId)) + .for("update"); + if (!hackathon?.max || participantIds.length === 0) return; + + const [seated] = await tx + .select({ n: sql`count(*)::int` }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + inArray(hackathonParticipants.registrationStatus, [...SEATED_STATUSES]), + ), + ); + const [adding] = await tx + .select({ n: sql`count(*)::int` }) + .from(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.hackathonId, hackathonId), + inArray(hackathonParticipants.id, participantIds), + notInArray(hackathonParticipants.registrationStatus, [ + ...SEATED_STATUSES, + ]), + ), + ); + + const left = Math.max(hackathon.max - (seated?.n ?? 0), 0); + if ((adding?.n ?? 0) > left) { + throw new TRPCError({ + code: "CONFLICT", + message: `Only ${left} seat${left === 1 ? "" : "s"} left of ${hackathon.max}; this would accept ${adding?.n ?? 0}. Raise the capacity or accept fewer.`, + }); + } +} + // Evicts exactly the keys a participant status change moves. The old // `deletePattern("hackathon*")` matched both namespaces, so one badge scan // wiped every attendee's cached registrations and the venue-wide events list. // Each affected user's own registration list goes too, or an acceptance lands -// in their inbox while their dashboard still says pending. -const evictParticipantCaches = ( +// in their inbox while their dashboard still says pending. The hackathon row +// goes as well: every caller has just synced currentParticipants, and getById +// serves the public "spots taken" line from that cached row. +export const evictParticipantCaches = ( cache: { delete: (key: string) => boolean }, hackathonId: string, userIds: string[], ) => { + cache.delete(CacheKeys.hackathon(hackathonId)); cache.delete(`hackathon:${hackathonId}:participants`); cache.delete(`hackathon:${hackathonId}:analytics`); for (const userId of new Set(userIds)) { @@ -248,17 +307,46 @@ export const hackathonAdminRouter = createTRPCRouter({ }); } - await (ctx.db as DrizzleDB) - .update(hackathonParticipants) - .set({ - registrationStatus: input.status, - // Stamp the arrival the first time only: re-checking someone in must not move - // the timestamp the attendees table shows. - ...(input.status === "checked_in" && !participant.checkedInAt - ? { checkedInAt: new Date() } - : {}), - }) - .where(eq(hackathonParticipants.id, input.participantId)); + await (ctx.db as DrizzleDB).transaction(async (tx) => { + // checked_in seats somebody too: the approved-only check above was + // read before this transaction, and a concurrent "waitlisted" would + // otherwise be checked in with no seat. + if (input.status === "approved" || input.status === "checked_in") { + await assertSeats(tx as unknown as DrizzleDB, input.hackathonId, [ + input.participantId, + ]); + } + const updated = await tx + .update(hackathonParticipants) + .set({ + registrationStatus: input.status, + // Stamp the arrival the first time only: re-checking someone in must not + // move the timestamp the attendees table shows. + ...(input.status === "checked_in" && !participant.checkedInAt + ? { checkedInAt: new Date() } + : {}), + }) + .where( + and( + eq(hackathonParticipants.id, input.participantId), + input.status === "checked_in" + ? inArray(hackathonParticipants.registrationStatus, [ + ...SEATED_STATUSES, + ]) + : undefined, + ), + ) + .returning({ id: hackathonParticipants.id }); + // A concurrent waitlist or reject leaves the seated-only WHERE matching + // nothing; reporting success would wave them through the desk. + if (updated.length === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This applicant's status changed just now. Refresh and check again before letting them in.", + }); + } + }); await syncCurrentParticipants(ctx.db as DrizzleDB, input.hackathonId); @@ -339,7 +427,7 @@ export const hackathonAdminRouter = createTRPCRouter({ }); } - const { wave, picked } = await db.transaction(async (tx) => { + const { wave, picked, full } = await db.transaction(async (tx) => { // Serialises waves for this hackathon. SKIP LOCKED below keeps two // concurrent waves from picking the same people, but both still read // the same max and were recorded as one wave number. @@ -360,6 +448,19 @@ export const hackathonAdminRouter = createTRPCRouter({ const wave = (highest?.max ?? 0) + 1; + // Never past capacity: a wave fills what is left and no more. + const [seats] = await tx + .select({ + max: hackathons.maxParticipants, + seated: sql`(select count(*)::int from ${hackathonParticipants} where ${hackathonParticipants.hackathonId} = ${input.hackathonId} and ${hackathonParticipants.registrationStatus} in ('approved', 'checked_in'))`, + }) + .from(hackathons) + .where(eq(hackathons.id, input.hackathonId)); + const size = seats?.max + ? Math.min(input.size, Math.max(seats.max - seats.seated, 0)) + : input.size; + if (size === 0) return { wave, picked: [], full: true }; + const picked = await tx .select({ id: hackathonParticipants.id, @@ -373,10 +474,10 @@ export const hackathonAdminRouter = createTRPCRouter({ ), ) .orderBy(hackathonParticipants.registeredAt) - .limit(input.size) + .limit(size) .for("update", { skipLocked: true }); - if (picked.length === 0) return { wave, picked }; + if (picked.length === 0) return { wave, picked, full: false }; await tx .update(hackathonParticipants) @@ -392,7 +493,7 @@ export const hackathonAdminRouter = createTRPCRouter({ ), ); - return { wave, picked }; + return { wave, picked, full: false }; }); if (picked.length === 0) { @@ -400,7 +501,9 @@ export const hackathonAdminRouter = createTRPCRouter({ wave, accepted: 0, participantIds: [] as string[], - message: "No pending applications left to accept.", + message: full + ? "Every seat is taken. Raise the capacity to accept more." + : "No pending applications left to accept.", }; } @@ -493,22 +596,30 @@ export const hackathonAdminRouter = createTRPCRouter({ // One statement rather than one per recipient: this runs against the full // accepted list, and a 500-round-trip transaction holds a pool connection for - // its whole duration. - await db - .update(hackathonParticipants) - .set({ registrationStatus: "approved", updatedAt: new Date() }) - .where( - and( - inArray( - hackathonParticipants.id, - participants.map((participant) => participant.id), - ), - eq(hackathonParticipants.hackathonId, hackathonId), - // Repeated in SQL for a check-in that lands after the read above: - // submitting a project requires checked_in. - ne(hackathonParticipants.registrationStatus, "checked_in"), - ), + // its whole duration. Checked against capacity first, in the same + // transaction, so the batch is accepted whole or not at all. + await db.transaction(async (tx) => { + await assertSeats( + tx as unknown as DrizzleDB, + hackathonId, + toAccept.map((participant) => participant.id), ); + await tx + .update(hackathonParticipants) + .set({ registrationStatus: "approved", updatedAt: new Date() }) + .where( + and( + inArray( + hackathonParticipants.id, + participants.map((participant) => participant.id), + ), + eq(hackathonParticipants.hackathonId, hackathonId), + // Repeated in SQL for a check-in that lands after the read above: + // submitting a project requires checked_in. + ne(hackathonParticipants.registrationStatus, "checked_in"), + ), + ); + }); // Approving a rejected or waitlisted applicant hands a seat back out. await syncCurrentParticipants(db, hackathonId); @@ -623,41 +734,50 @@ export const hackathonAdminRouter = createTRPCRouter({ // One statement, not one per id: 2000 sequential round trips would hold a // pool connection for the whole batch. The (id, hackathonId) scoping survives // in the AND, and the caller is told how many rows really changed. - const rows = await (ctx.db as DrizzleDB) - .update(hackathonParticipants) - .set({ - registrationStatus: status, - updatedAt: new Date(), - // coalesce so re-checking in someone who already arrived keeps their original - // time. `at time zone 'utc'` because the column is timestamp-without-tz: a - // bare now() goes through the session TimeZone and would disagree with the - // `new Date()` updateParticipantStatus writes for the same event. - ...(status === "checked_in" - ? { - checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`, - } - : {}), - }) - .where( - and( - inArray(hackathonParticipants.id, participantIds), - eq(hackathonParticipants.hackathonId, hackathonId), - // Same rule as the single-participant path, in the WHERE so a 2000-row - // selection with a few unreviewed applicants still admits everybody else. - // It matters more here: "Select all N matching" means one wrong click could - // promote every pending applicant to a state that lets them submit. - status === "checked_in" - ? inArray(hackathonParticipants.registrationStatus, [ - "approved", - "checked_in", - ]) - : undefined, - ), - ) - .returning({ - id: hackathonParticipants.id, - userId: hackathonParticipants.userId, - }); + const rows = await (ctx.db as DrizzleDB).transaction(async (tx) => { + if (status === "approved") { + await assertSeats( + tx as unknown as DrizzleDB, + hackathonId, + participantIds, + ); + } + return tx + .update(hackathonParticipants) + .set({ + registrationStatus: status, + updatedAt: new Date(), + // coalesce so re-checking in someone who already arrived keeps their original + // time. `at time zone 'utc'` because the column is timestamp-without-tz: a + // bare now() goes through the session TimeZone and would disagree with the + // `new Date()` updateParticipantStatus writes for the same event. + ...(status === "checked_in" + ? { + checkedInAt: sql`coalesce(${hackathonParticipants.checkedInAt}, now() at time zone 'utc')`, + } + : {}), + }) + .where( + and( + inArray(hackathonParticipants.id, participantIds), + eq(hackathonParticipants.hackathonId, hackathonId), + // Same rule as the single-participant path, in the WHERE so a 2000-row + // selection with a few unreviewed applicants still admits everybody else. + // It matters more here: "Select all N matching" means one wrong click could + // promote every pending applicant to a state that lets them submit. + status === "checked_in" + ? inArray(hackathonParticipants.registrationStatus, [ + "approved", + "checked_in", + ]) + : undefined, + ), + ) + .returning({ + id: hackathonParticipants.id, + userId: hackathonParticipants.userId, + }); + }); await syncCurrentParticipants(ctx.db as DrizzleDB, hackathonId); diff --git a/packages/api/src/routers/hackathon/interest.ts b/packages/api/src/routers/hackathon/interest.ts index 22007f4d..f37be48f 100644 --- a/packages/api/src/routers/hackathon/interest.ts +++ b/packages/api/src/routers/hackathon/interest.ts @@ -59,7 +59,12 @@ const CLAIM_TIMEOUT_MS = 15 * 60 * 1000; // meant that the moment registration opened, the one page telling the world // about the hackathon said "Nothing announced yet". Soonest first, so // announcing the year after next does not displace the one being promoted. -const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const; +const PUBLIC_FUNNEL_STATUSES = [ + "announced", + "open", + "closed", + "in_progress", +] as const; /** What the landing page reads; also the shape held in the cache. */ type UpcomingEdition = { diff --git a/packages/api/src/routers/hackathon/registration.ts b/packages/api/src/routers/hackathon/registration.ts index 84b90b30..e4db769a 100644 --- a/packages/api/src/routers/hackathon/registration.ts +++ b/packages/api/src/routers/hackathon/registration.ts @@ -5,11 +5,13 @@ import { CacheKeys } from "../../middleware/cache"; import { hackathons, hackathonParticipants, + hackathonProjects, members, } from "@query/db"; -import { eq, and, sql } from "drizzle-orm"; +import { eq, and, isNull, ne } from "drizzle-orm"; import type { DrizzleDB } from "@query/db"; import { assertHackathonVisible } from "./visibility"; +import { evictParticipantCaches, syncCurrentParticipants } from "./admin"; // Postgres unique_violation on hackathon_participant_hackathon_user_idx — a second // submission of the same form. Drizzle wraps every driver error in a @@ -51,7 +53,13 @@ export const hackathonRegistrationRouter = createTRPCRouter({ // Academic info school: z.string().min(1).max(300), major: z.string().min(1).max(300), - graduationYear: z.number().int().min(2020).max(2035), + // Relative to now: a fixed 2020-2035 accepted years already past and + // would start refusing real students in 2036. + graduationYear: z + .number() + .int() + .min(new Date().getFullYear() - 1) + .max(new Date().getFullYear() + 8), levelOfStudy: z.enum([ "Freshman", "Sophomore", @@ -138,18 +146,9 @@ export const hackathonRegistrationRouter = createTRPCRouter({ }); } - // Nothing is locked yet, so this only turns away a form submitted against an - // event that was already visibly full; the seat is claimed and checked below. - if ( - hackathon.maxParticipants && - hackathon.currentParticipants >= hackathon.maxParticipants - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This hackathon is full", - }); - } - + // No seat is claimed here: capacity counts accepted people, not + // applications, so applying is never refused for being full. The + // seat is taken when an organiser accepts (admin.ts assertSeats). // A membership is annual and edition-independent, so it is keyed on the // person alone; the edition clause used to be here and made a paying member // read as a non-member the moment a new edition opened. @@ -157,35 +156,6 @@ export const hackathonRegistrationRouter = createTRPCRouter({ where: eq(members.userId, ctx.userId as string), }); - // Claiming the seat before inserting anything is what makes capacity hold - // across processes: this statement takes the hackathon row's exclusive lock, - // so a registration racing for the same last seat blocks here and re-runs - // `+ 1` against the count we wrote rather than its own snapshot. Reading the - // row back in the same transaction gives the seat this registration actually - // holds, and going over the limit rolls the claim back. It also keeps - // admin.ts's recount honest — that path locks the same row first. - await tx - .update(hackathons) - .set({ - currentParticipants: sql`${hackathons.currentParticipants} + 1`, - }) - .where(eq(hackathons.id, input.hackathonId)); - - const claimed = await tx.query.hackathons.findFirst({ - where: eq(hackathons.id, input.hackathonId), - columns: { currentParticipants: true, maxParticipants: true }, - }); - - if ( - claimed?.maxParticipants && - claimed.currentParticipants > claimed.maxParticipants - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This hackathon is full", - }); - } - const [participant] = await tx .insert(hackathonParticipants) .values({ @@ -260,6 +230,103 @@ export const hackathonRegistrationRouter = createTRPCRouter({ }), + // Takes back your own registration. There was no way out at all, so an + // accepted no-show kept a seat for good. Refused once you are part of the + // event — on a team, checked in, or with a project — since undoing any of + // those affects other people and is an organiser's call. + withdrawRegistration: protectedProcedure + .input(z.object({ hackathonId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const participant = await db.query.hackathonParticipants.findFirst({ + where: and( + eq(hackathonParticipants.hackathonId, input.hackathonId), + eq(hackathonParticipants.userId, ctx.userId as string), + ), + columns: { id: true, teamId: true, registrationStatus: true }, + }); + + if (!participant) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "You are not registered for this hackathon.", + }); + } + + // A finished event's registrations are its record of who took part. + const hackathon = await db.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { status: true }, + }); + if ( + hackathon?.status === "completed" || + hackathon?.status === "cancelled" + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "This hackathon is over, so registrations can't be withdrawn.", + }); + } + + if (participant.registrationStatus === "checked_in") { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "You have already checked in. Ask an organiser if you need to leave the event.", + }); + } + if (participant.teamId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Leave your team first, then withdraw.", + }); + } + + const project = await db.query.hackathonProjects.findFirst({ + where: and( + eq(hackathonProjects.hackathonId, input.hackathonId), + eq(hackathonProjects.submittedById, participant.id), + ), + columns: { id: true }, + }); + if (project) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Withdraw your project first, then your registration.", + }); + } + + // The checks above are a read; a badge scan or a team join can land + // before this. Repeating them here deletes nothing in that case. + const removed = await db + .delete(hackathonParticipants) + .where( + and( + eq(hackathonParticipants.id, participant.id), + ne(hackathonParticipants.registrationStatus, "checked_in"), + isNull(hackathonParticipants.teamId), + ), + ) + .returning({ id: hackathonParticipants.id }); + if (removed.length === 0) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Your registration changed just now. Refresh the page and try again.", + }); + } + + // An accepted withdrawal frees a seat for the next wave. + await syncCurrentParticipants(db, input.hackathonId); + + evictParticipantCaches(ctx.cache, input.hackathonId, [ + ctx.userId as string, + ]); + + return { success: true }; + }), + myRegistrations: protectedProcedure.query(async ({ ctx }) => { const cacheKey = `hackathon:registrations:${ctx.userId}`; const cached = ctx.cache.get(cacheKey); diff --git a/packages/api/src/routers/judge/admin.ts b/packages/api/src/routers/judge/admin.ts index 17f228d9..4edc8322 100644 --- a/packages/api/src/routers/judge/admin.ts +++ b/packages/api/src/routers/judge/admin.ts @@ -338,29 +338,42 @@ export const judgeAdminRouter = createTRPCRouter({ return allJudges; }), + // Adds a judge directly — a sponsor or a walk-in — rather than waiting for + // them to apply. By email, because that is what an organiser has; the + // person needs an account, which signing in once creates. create: isAdmin .input( - z.object({ - userId: z.string().min(1).max(255), - hackathonId: z.string().uuid(), - name: z.string().max(255).optional(), - }), + z + .object({ + userId: z.string().min(1).max(255).optional(), + email: z.string().trim().email().max(255).optional(), + hackathonId: z.string().uuid(), + name: z.string().max(255).optional(), + }) + .refine((input) => !!input.userId || !!input.email, { + message: "Give the judge's email address.", + path: ["email"], + }), ) .mutation(async ({ ctx, input }) => { const user = await (ctx.db as DrizzleDB).query.users.findFirst({ - where: eq(users.id, input.userId), + where: input.userId + ? eq(users.id, input.userId) + : sql`lower(${users.email}) = lower(${input.email!})`, }); if (!user) { throw new TRPCError({ code: "NOT_FOUND", - message: "User not found", + message: input.userId + ? "User not found" + : "No account uses that email. Ask them to sign in to the portal once, then add them.", }); } const existing = await (ctx.db as DrizzleDB).query.judges.findFirst({ where: and( - eq(judges.userId, input.userId), + eq(judges.userId, user.id), eq(judges.hackathonId, input.hackathonId), ), }); @@ -375,17 +388,18 @@ export const judgeAdminRouter = createTRPCRouter({ const result = await (ctx.db as DrizzleDB) .insert(judges) .values({ - userId: input.userId, + userId: user.id, hackathonId: input.hackathonId, name: input.name || user.name, + email: user.email, isActive: true, // Manually created judges are active by default }) .returning(); // Same as approval: the role gate and the sidebar both cache, so the new // judge would otherwise wait out a 5-minute TTL for the Judge tab. - ctx.cache.deletePattern(`${CacheKeys.judge(input.userId)}*`); - invalidatePortalContext(input.userId); + ctx.cache.deletePattern(`${CacheKeys.judge(user.id)}*`); + invalidatePortalContext(user.id); return result[0]; }), @@ -680,8 +694,9 @@ export const judgeAdminRouter = createTRPCRouter({ // Approve or suspend a judge. judge.register creates the row inactive and // judge.create refuses once it exists, so without this a self-registered - // judge can never be activated by any route. - setActive: isSuperAdmin + // judge can never be activated by any route. Any admin: it was super-admin + // only while every admin saw the button, so most organisers got an error. + setActive: isAdmin .input( z.object({ judgeId: z.string().uuid(), @@ -1566,6 +1581,26 @@ export const judgeAdminRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { return await (ctx.db as DrizzleDB).transaction(async (tx) => { + // Only an edition people can see and that is not over takes judges. Any + // uuid used to pass, and one that did not exist hit the foreign key as + // a 500. Announced counts: judges are recruited before registration. + const hackathon = await tx.query.hackathons.findFirst({ + where: eq(hackathons.id, input.hackathonId), + columns: { isPublic: true, status: true }, + }); + if ( + !hackathon || + !hackathon.isPublic || + !["announced", "open", "closed", "in_progress"].includes( + hackathon.status, + ) + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "That hackathon is not taking judge applications.", + }); + } + // Check if user is registered as a participant for this hackathon const participant = await tx.query.hackathonParticipants.findFirst({ where: and( diff --git a/packages/api/src/routers/judge/portal.ts b/packages/api/src/routers/judge/portal.ts index eba687b2..c7229631 100644 --- a/packages/api/src/routers/judge/portal.ts +++ b/packages/api/src/routers/judge/portal.ts @@ -341,6 +341,25 @@ export const judgePortalRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { + // Opening judging with nothing queued sent every judge straight to "All + // done, 0 of 0". Preparing (promote submissions, assign judges) is a + // separate step on the Judging page, so say so rather than open empty. + if (input.active) { + const [queued] = await (ctx.db as DrizzleDB) + .select({ n: sql`count(*)::int` }) + .from(judgeQueue) + // Any row, scored or not: reopening judging so a judge can fix a + // score, after every slot is done, must still be allowed. + .where(eq(judgeQueue.hackathonId, input.hackathonId)); + if ((queued?.n ?? 0) === 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "No judge has a table to score yet. Prepare judging first: promote submissions and assign judges on the Judging page.", + }); + } + } + const [updated] = await (ctx.db as DrizzleDB) .update(hackathons) .set({ judgingActive: input.active, updatedAt: new Date() }) diff --git a/packages/api/src/routers/team.ts b/packages/api/src/routers/team.ts index 37491328..ee243934 100644 --- a/packages/api/src/routers/team.ts +++ b/packages/api/src/routers/team.ts @@ -19,23 +19,25 @@ type Tx = Parameters[0]>[0]; const HOUR = 60 * 60 * 1000; /** All hackathon milestones, as hour offsets from the hacking start time. */ -const TEAM_WINDOW_OPEN_HOURS = 12; +const SUBMISSION_OPEN_HOURS = 12; const TEAM_WINDOW_CLOSE_HOURS = 34; const SUBMISSION_HARD_DEADLINE_HOURS = 36; /** Rosters freeze 12h before the hard submission deadline, i.e. at +24h. */ const LEAVE_LOCK_HOURS = SUBMISSION_HARD_DEADLINE_HOURS - 12; +// Teams form any time before +34h, so accepted people can find teammates +// before kickoff. Who may form one is checkAdmitted's job, not the clock's: +// this window used to open only at +12h, a third of the way into the event. export function computeTeamWindow(baseTime: Date, now: Date) { const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR); - const opensAt = at(TEAM_WINDOW_OPEN_HOURS); const closesAt = at(TEAM_WINDOW_CLOSE_HOURS); const leaveLocksAt = at(LEAVE_LOCK_HOURS); - const isOpen = now >= opensAt && now <= closesAt; + const isOpen = now <= closesAt; return { - opensAt, + opensAt: null, closesAt, leaveLocksAt, isOpen, @@ -54,7 +56,7 @@ export function computeTeamWindow(baseTime: Date, now: Date) { export function computeSubmissionWindow(baseTime: Date, now: Date) { const at = (hours: number) => new Date(baseTime.getTime() + hours * HOUR); - const opensAt = at(TEAM_WINDOW_OPEN_HOURS); + const opensAt = at(SUBMISSION_OPEN_HOURS); /** After this, an existing submission is frozen — new ones still land. */ const editsCloseAt = at(TEAM_WINDOW_CLOSE_HOURS); const closesAt = at(SUBMISSION_HARD_DEADLINE_HOURS); @@ -115,13 +117,6 @@ async function checkTeamEditWindow(db: DrizzleDB, hackathonId: string) { const window = await loadTeamWindow(db, hackathonId); const now = new Date(); - if (now < window.opensAt) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Team creation and editing is not open yet. It starts 12 hours after the hacking begins.", - }); - } if (now > window.closesAt) { throw new TRPCError({ code: "FORBIDDEN", @@ -1142,8 +1137,19 @@ export const teamRouter = createTRPCRouter({ // belongs to the captain rather than the caller. columns: { submittedById: false }, }); + if (!project) return null; + + // Judges are sent to a table number; the team is the one party that was + // never told it. Null until the project is promoted to judging. + const judging = await db.query.judgingProjects.findFirst({ + where: and( + eq(judgingProjects.sourceProjectId, project.id), + isNull(judgingProjects.withdrawnAt), + ), + columns: { tableNumber: true }, + }); - return project ?? null; + return { ...project, tableNumber: judging?.tableNumber ?? null }; }), // Every project the caller owns, across hackathons. Reading off the team diff --git a/sites/mainweb/app/(portal)/admin/analytics/page.tsx b/sites/mainweb/app/(portal)/admin/analytics/page.tsx index 00aefe34..29a5be46 100644 --- a/sites/mainweb/app/(portal)/admin/analytics/page.tsx +++ b/sites/mainweb/app/(portal)/admin/analytics/page.tsx @@ -83,12 +83,12 @@ function StatCard({ icon: Icon, title, value, subtitle }: StatCardProps) {
-

{title}

+

{title}

{value}

{subtitle && ( - {subtitle} + {subtitle} )} {/* Decorative accent line */}
@@ -277,7 +277,7 @@ export default function AnalyticsPage() {

Analytics Dashboard

-

+

Membership growth, bootcamp enrolment, and turnout across every event and hackathon.

diff --git a/sites/mainweb/app/(portal)/admin/attendees/page.tsx b/sites/mainweb/app/(portal)/admin/attendees/page.tsx index ec82a665..05dc07c3 100644 --- a/sites/mainweb/app/(portal)/admin/attendees/page.tsx +++ b/sites/mainweb/app/(portal)/admin/attendees/page.tsx @@ -89,7 +89,7 @@ export default function AttendeesPage() { Attendees{" "} Registry -

+

View and manage attendee check-ins for club meetings. Hackathon applications live on each edition's dashboard.

@@ -131,7 +131,7 @@ export default function AttendeesPage() { {isLoading ? (
-

+

Loading event check-ins...

@@ -139,7 +139,7 @@ export default function AttendeesPage() {
No attendees yet -

+

Select an event to view check-ins.

@@ -166,16 +166,16 @@ export default function AttendeesPage() {
- - - - diff --git a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx index a2e76496..ac679d1c 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx @@ -15,7 +15,16 @@ import { EventsTab } from "@/components/admin/hackathons/EventsTab"; import { JudgesTab } from "@/components/admin/hackathons/JudgesTab"; import { AnnouncementsTab } from "@/components/admin/hackathons/AnnouncementsTab"; import { TableCards } from "@/components/admin/hackathons/TableCards"; -import { Gavel, Megaphone, QrCode } from "lucide-react"; +import { + BarChart3, + Calendar, + ChevronLeft, + Gavel, + Megaphone, + QrCode, + ScanLine, + Users, +} from "lucide-react"; type Tab = | "events" @@ -80,18 +89,18 @@ export default function AdminHackathonDashboard() { { id: "attendees", label: "Applications", - icon: , + icon: , }, { id: "events", - label: "Hackathon Events", - icon: , + label: "Events", + icon: , }, - { id: "scanner", label: "Scan", icon: }, + { id: "scanner", label: "Scan", icon: }, { id: "analytics", label: "Stats", - icon: , + icon: , }, { id: "judges", label: "Judges", icon: }, { @@ -107,47 +116,21 @@ export default function AdminHackathonDashboard() { ]; return ( -
- {/* Ambient Background */} -
-
-
-
-
- - {/* HEADER - Enhanced */} +
-
- - - -
- - Hackathons - + + Hackathons -

+

{hackathon.name} - {/* Animated underline */} -

@@ -157,25 +140,10 @@ export default function AdminHackathonDashboard() { )}
@@ -183,32 +151,21 @@ export default function AdminHackathonDashboard() {
- {/* DESKTOP TABS - Enhanced, Hidden on mobile */} -
- {/* Tab background gradients */} -
- + {/* DESKTOP TABS - Hidden on mobile */} +
{tabs.map((tab) => ( ))}
@@ -237,44 +194,28 @@ export default function AdminHackathonDashboard() {
- {/* MOBILE BOTTOM NAVIGATION - Enhanced */} + {/* MOBILE BOTTOM NAVIGATION */} {/* Scrolls horizontally rather than a fixed 5-column grid: there are seven tabs, so Table Cards and Email were laid out off the right edge of the phone an organiser actually carries — unreachable, with nothing to suggest they existed. */}
- {/* Top gradient overlay */} -
- {tabs.map((tab) => ( ))}
@@ -291,11 +232,11 @@ function DashboardUnavailable({ }) { return (
-
-

+
+

Hackathon unavailable

-

+

{message}

@@ -303,14 +244,14 @@ function DashboardUnavailable({ )} Back to hackathons @@ -319,76 +260,3 @@ function DashboardUnavailable({
); } - -// Icons -function IconScanner({ className }: { className?: string }) { - return ( - - - - ); -} - -function IconUsers({ className }: { className?: string }) { - return ( - - - - ); -} - -function IconChart({ className }: { className?: string }) { - return ( - - - - ); -} - -function IconCalendar({ className }: { className?: string }) { - return ( - - - - ); -} diff --git a/sites/mainweb/app/(portal)/admin/hackathons/error.tsx b/sites/mainweb/app/(portal)/admin/hackathons/error.tsx index 3381dcfb..095ee9dd 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/error.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { useChunkErrorRecovery } from "@/lib/chunk-error"; @@ -20,30 +21,16 @@ export default function AdminError({ }, [error]); return ( -
+
- -
- - - -
+ + -

- {isChunkError ? "New Version Available" : "Admin Protocol Error"} +

+ {isChunkError ? "New version available" : "Couldn't load this page"}

-

+

{isChunkError ? ( <> The app was updated while this tab was open, so part of the old @@ -52,14 +39,14 @@ export default function AdminError({ ) : ( <> - The admin module encountered a critical fault during execution. - Please verify your clearance and try again. + Something went wrong. Try again; if it persists, the message + below will help whoever debugs it. )}

- - {error.message || "Unknown execution exception"} + + {error.message || "No error message"}

@@ -69,15 +56,15 @@ export default function AdminError({ onClick={() => isChunkError ? window.location.reload() : reset() } - className="px-6 py-3 bg-accent text-black font-black uppercase tracking-widest text-sm rounded-none hover:bg-white transition-colors" + className="px-6 py-3 bg-accent text-[var(--text-on-accent)] rounded-sm font-bold text-sm uppercase tracking-widest hover:bg-[var(--accent-secondary)] transition-ui disabled:opacity-50" > - {isChunkError ? "Reload Page" : "Retry Execution"} + {isChunkError ? "Reload page" : "Try again"} - Abort to Dashboard + Back to dashboard
diff --git a/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx b/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx index 7c586b1d..04d136ed 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/loading.tsx @@ -3,5 +3,5 @@ import { LoadingScreen } from "@/components/portal/LoadingScreen"; export default function Loading() { - return ; + return ; } diff --git a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx index 37a9926c..d3c2123a 100644 --- a/sites/mainweb/app/(portal)/admin/hackathons/page.tsx +++ b/sites/mainweb/app/(portal)/admin/hackathons/page.tsx @@ -13,7 +13,7 @@ import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { HackathonCard } from "@/components/admin/hackathons/HackathonCard"; import { CreateHackathonForm } from "@/components/admin/hackathons/CreateHackathonForm"; import { EditHackathonForm } from "@/components/admin/hackathons/EditHackathonForm"; -import { Zap } from "lucide-react"; +import { Layers, Plus, Zap } from "lucide-react"; export default function AdminHackathonsPage() { const { data: session, status } = useSession(); @@ -23,7 +23,13 @@ export default function AdminHackathonsPage() { const [showCreate, setShowCreate] = useState(false); const [editingId, setEditingId] = useState(null); - const { data: hackathons, isLoading } = trpc.hackathon.listAll.useQuery( + const { + data: hackathons, + isLoading, + isError, + error, + refetch, + } = trpc.hackathon.listAll.useQuery( undefined, { enabled: !!session }, ); @@ -37,32 +43,24 @@ export default function AdminHackathonsPage() { return ( <>
- {/* Ambient Background */} -
-
-
-
- -
-
-
- -

- Hackathon Hub +

+

+ Admin

-

- Hackathon Manager +

+ Hackathons

-

+

Manage your hackathons, participants, and event check-in locations.

@@ -91,37 +89,40 @@ export default function AdminHackathonsPage() { )}
- {isLoading || hackathons === undefined ? ( + {/* Checked before the skeleton: a failed query leaves data + undefined, which used to hold the skeleton up forever. */} + {isError ? ( + +

+ Couldn't load hackathons. {error.message} +

+ +
+ ) : isLoading || hackathons === undefined ? (
{[1, 2, 3].map((n) => (
))}
) : hackathons.length === 0 ? ( - -
- - - + +
+
-

- No hackathons yet -

-

- Create your first hackathon to get started. +

+ No hackathons yet. Create your first one to get started.

) : ( diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index d2ac785d..f4ee827b 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -2,12 +2,11 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; import { loginHref } from "@/lib/safe-callback"; -import { Zap } from "lucide-react"; import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; import { useIsClient } from "@/lib/use-is-client"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { JudgingTools } from "@/components/admin/judging/JudgingTools"; import { RoomAssignmentsView } from "@/components/admin/judging/RoomAssignmentsView"; @@ -21,8 +20,11 @@ export default function AdminResultsPage() { const { data: session, status } = useSession(); const router = useRouter(); const mounted = useIsClient(); + // ?hackathonId= preselects the event, so the hackathon dashboard's + // "Prepare judging" link lands on the right one. + const searchParams = useSearchParams(); const [selectedHackathon, setSelectedHackathon] = useState( - null, + searchParams.get("hackathonId"), ); const [selectedCategory, setSelectedCategory] = useState("ALL"); const [selectedTrack, setSelectedTrack] = useState("ALL"); @@ -273,105 +275,50 @@ export default function AdminResultsPage() { return ( <>
- {/* Animated Background */} -
-
-
-
-
- - {/* Header - Enhanced */} -
-
-
-
- -
-
-
- - - -
-
-

- Hackathon Hub -

-

- Voting Results -

-

- - Data Evaluation Layer //{" "} - {selectedHackathon ? "SYNC ACTIVE" : "IDLE"} -

-
-
-
-
- - {/* Judging Control Panel - Enhanced */} +
+

+ Admin +

+

+ Judging results +

+

+ {!selectedHackathon + ? "Pick a hackathon to see its results." + : judgingStatus?.active + ? "Judging is live." + : "Judging is closed."} +

+
+ + {/* Judging Control Panel */} {selectedHackathon && ( - {/* Background gradients */} - {/* Decorative only. Without pointer-events-none it is an absolutely - positioned layer painting above every static sibling that - follows it, which swallowed the clicks on the controls below. */} -

-

+

Club meetings and workshops. Bootcamp sessions are managed on the{" "}

{event.description && ( -

+

{event.description}

)} diff --git a/sites/mainweb/app/(portal)/admin/projects/page.tsx b/sites/mainweb/app/(portal)/admin/projects/page.tsx index 76843793..5fbb8d04 100644 --- a/sites/mainweb/app/(portal)/admin/projects/page.tsx +++ b/sites/mainweb/app/(portal)/admin/projects/page.tsx @@ -105,7 +105,7 @@ export default function ProjectsPage() { Projects{" "} Manager -

+

Browse and manage hackathon projects submitted by participants.

diff --git a/sites/mainweb/app/(portal)/auth/error/page.tsx b/sites/mainweb/app/(portal)/auth/error/page.tsx index e8c2d3a5..ef48f363 100644 --- a/sites/mainweb/app/(portal)/auth/error/page.tsx +++ b/sites/mainweb/app/(portal)/auth/error/page.tsx @@ -3,6 +3,8 @@ import React, { Suspense } from "react"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; function AuthErrorContent() { const searchParams = useSearchParams(); @@ -10,20 +12,20 @@ function AuthErrorContent() { const errorMessages: Record = { Configuration: { - title: "Service_Unavailable", + title: "Sign-in is unavailable", desc: "Sign-in is unavailable right now. Please try again later.", }, AccessDenied: { - title: "Access_Denied", - desc: "You do not have permission to sign in. Your account may not be authorized.", + title: "Access denied", + desc: "You do not have permission to sign in.", }, Verification: { - title: "Verification_Failed", + title: "Link expired", desc: "The sign-in link is no longer valid or has already been used.", }, Default: { - title: "Authentication_Failure", - desc: "An unexpected error occurred during the authentication process.", + title: "Sign-in failed", + desc: "We couldn't sign you in. Try again, or use a different sign-in method.", }, }; @@ -31,64 +33,29 @@ function AuthErrorContent() { (error && errorMessages[error]) || errorMessages.Default; return ( -
-
- - - -
+
+ -
-

- Auth_Error +
+

+ {title}

-

- Status_Code: 401 -

-
-

{title}

-

- > {desc} -

-
- -
- - < Return_To_Base - +

{desc}

-
-

- Query_Security_Protocols_Active -

-
+ + Back to sign in +
); } export default function AuthErrorPage() { return ( - - Loading_Error_Log... -

- } - > + }> ); diff --git a/sites/mainweb/app/(portal)/dashboard/page.tsx b/sites/mainweb/app/(portal)/dashboard/page.tsx index 7ec27ba2..cf1cfd69 100644 --- a/sites/mainweb/app/(portal)/dashboard/page.tsx +++ b/sites/mainweb/app/(portal)/dashboard/page.tsx @@ -13,6 +13,7 @@ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import LinkStripeAccount from "@/components/portal/LinkStripeAccount"; +import { StatusBadge } from "@/components/hackathon/StatusBadge"; import { MEMBERSHIP_CENTS, SEMESTER_MEMBERSHIP_CENTS, @@ -26,59 +27,10 @@ import { Shield, Users, ArrowRight, - Clock, - CheckCircle, - XCircle, - AlertCircle, Gavel, Rocket, } from "lucide-react"; -function StatusBadge({ status }: { status: string }) { - const map: Record< - string, - { label: string; color: string; icon: React.ReactNode } - > = { - approved: { - label: "Approved", - color: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20", - icon: , - }, - checked_in: { - label: "Checked In", - color: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20", - icon: , - }, - waitlisted: { - label: "Waitlisted", - color: "text-amber-500 bg-amber-500/10 border-amber-500/20", - icon: , - }, - pending: { - label: "Pending", - color: "text-amber-500 bg-amber-500/10 border-amber-500/20", - icon: , - }, - rejected: { - label: "Rejected", - color: "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-medium)]", - icon: , - }, - }; - const cfg = map[status] ?? { - label: status, - color: - "text-[var(--text-subtle)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", - icon: , - }; - return ( - - {cfg.icon} {cfg.label} - - ); -} export default function Dashboard() { const { data: session, status } = useSession(); diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/error.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/error.tsx index 8cc68d3f..2ae3886e 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/error.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/error.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect } from "react"; -import Background from "@/components/Background"; import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { useChunkErrorRecovery } from "@/lib/chunk-error"; @@ -19,53 +19,37 @@ export default function Error({ }, [error]); return ( -
- - +
- -
- - - -
+ + diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index 60f16359..8029c21b 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -10,6 +10,7 @@ import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { QRScannerModal } from "@/components/portal/QRScannerModal"; import { decodeHackathonParam } from "@/lib/hackathon-slug"; +import { ChevronLeft } from "lucide-react"; type Project = { id: string; @@ -282,7 +283,7 @@ export default function JudgeHackathonPage() { }, [status, router]); if (status === "loading") { - return ; + return ; } if (!session) return null; @@ -295,15 +296,15 @@ export default function JudgeHackathonPage() { return (
-

+

Hackathon Not Found

-

+

{resolved.error?.message ?? "That hackathon does not exist."}

Back to Judge Portal @@ -313,22 +314,22 @@ export default function JudgeHackathonPage() { } if (judgeCheck.isPending) { - return ; + return ; } if (judgeCheck.data && !judgeCheck.data.isJudge) { return (
-

+

Not a Judge

-

+

Your judge account for this hackathon has not been approved yet.

Back to Judge Portal @@ -346,10 +347,10 @@ export default function JudgeHackathonPage() { return (
-

+

Judging Not Open

-

+

Scoring has not been opened for this hackathon yet. Your queue is waiting — an organiser will start judging when the expo begins.

@@ -357,13 +358,13 @@ export default function JudgeHackathonPage() { Back to Judge Portal @@ -383,16 +384,16 @@ export default function JudgeHackathonPage() { return (
-

+

Could Not Load Your Queue

-

+

{nextTable.error.message}

@@ -412,24 +413,25 @@ export default function JudgeHackathonPage() { complete.isPending || skip.isPending || forceSkip.isPending; return ( -
+
- ← Judge Portal + + Judging {progress.data && ( -

+

{progress.data.completed} / {progress.data.total} judged

)}
{progress.data && progress.data.total > 0 && ( -
+
-

{error}

+
+

{error}

)} {done || !project ? ( + // An empty queue is not a finished one: "All done, 0 of 0" told a + // judge with nothing assigned that they had judged everything. Nor is + // an unknown one: "All done" waits until progress says there was work. -

- All Done +

+ {progress.data?.total === 0 + ? "No tables yet" + : (progress.data?.total ?? 0) > 0 + ? "All done" + : "Your queue"}

-

- You have judged every project assigned to you. Thank you. +

+ {progress.data?.total === 0 + ? "No projects are assigned to you yet. Tell an organiser; they assign tables from the Judging page." + : (progress.data?.total ?? 0) > 0 + ? "You have judged every project assigned to you. Thank you." + : progress.isError + ? "Couldn't load your progress. Refresh the page." + : "Loading your queue…"}

) : ( <>
{project.tableNumber != null && ( -

+

Table {project.tableNumber}

)} -

+

{project.name}

{project.description && ( -

+

{project.description}

)} @@ -481,7 +496,7 @@ export default function JudgeHackathonPage() { href={l.href as string} target="_blank" rel="noopener noreferrer" - className="px-4 py-2 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[10px] uppercase tracking-widest hover:bg-white/5 transition-colors" + className="px-5 py-2.5 rounded-sm border border-[var(--border-medium)] bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] hover:border-[var(--border-hover)] transition-ui text-xs font-bold uppercase tracking-widest" > {l.label} @@ -494,7 +509,7 @@ export default function JudgeHackathonPage() { and let a judge score a table they never actually reached. */} {!arrived ? ( -

+

Scan the team's code to begin

@@ -510,7 +525,7 @@ export default function JudgeHackathonPage() { setShowScanner(true); }} disabled={startByQr.isPending} - className="px-10 py-5 bg-accent text-black font-black text-sm uppercase tracking-widest rounded-none hover:bg-white transition-ui active:scale-95 disabled:opacity-40" + className="px-6 py-3 bg-accent text-[var(--text-on-accent)] rounded-sm font-bold text-sm uppercase tracking-widest hover:bg-[var(--accent-secondary)] transition-ui disabled:opacity-50" > {startByQr.isPending ? "Starting…" : "Scan Table Code"} @@ -522,7 +537,7 @@ export default function JudgeHackathonPage() {

@@ -551,7 +566,7 @@ export default function JudgeHackathonPage() {
@@ -562,16 +577,16 @@ export default function JudgeHackathonPage() { rows={4} maxLength={1000} placeholder="What stood out?" - className="w-full px-4 py-3 bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-sm placeholder:text-gray-600 focus:border-accent/50 focus:outline-none resize-none" + className="w-full bg-[var(--bg-secondary)] border border-[var(--border-subtle)] rounded-sm px-4 py-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-subtle)] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-ui resize-none" />
{stranded && (
-

+

This is the last table left in your queue, so there is nothing to skip to. If nobody is here, hand it to another judge instead — it will still get scored. @@ -584,7 +599,7 @@ export default function JudgeHackathonPage() { if (current?.queueId) forceSkip.mutate({ queueId: current.queueId }); }} - className="mt-3 px-4 py-2 border border-amber-500/40 bg-amber-500/10 text-amber-200 font-mono text-xs uppercase tracking-widest hover:bg-amber-500/20 transition-colors disabled:opacity-30" + className="mt-3 px-4 py-2 rounded-sm border border-amber-500/40 bg-amber-500/10 text-amber-200 text-xs font-bold uppercase tracking-widest hover:bg-amber-500/20 transition-colors disabled:opacity-30" > {forceSkip.isPending ? "Reassigning…" @@ -594,7 +609,7 @@ export default function JudgeHackathonPage() { )}

-

+

Total {total}{" "} / 50

@@ -607,7 +622,7 @@ export default function JudgeHackathonPage() { if (current?.queueId) skip.mutate({ queueId: current.queueId }); }} - className="px-6 py-3 border border-[var(--border-subtle)] text-text-muted font-mono text-xs uppercase tracking-widest hover:bg-white/5 transition-colors disabled:opacity-30" + className="px-5 py-2.5 rounded-sm border border-[var(--border-medium)] bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] hover:border-[var(--border-hover)] transition-ui text-xs font-bold uppercase tracking-widest disabled:opacity-30" > {skip.isPending ? "Skipping…" : "Skip for now"} @@ -628,7 +643,7 @@ export default function JudgeHackathonPage() { comment: comment || undefined, }); }} - className="px-8 py-3 bg-accent text-black font-black text-xs uppercase tracking-widest hover:bg-white transition-colors disabled:opacity-30" + className="px-6 py-3 bg-accent text-[var(--text-on-accent)] rounded-sm font-bold text-sm uppercase tracking-widest hover:bg-[var(--accent-secondary)] transition-ui disabled:opacity-50" > {complete.isPending ? "Saving…" : "Submit & Next"} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/loading.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/loading.tsx index c19b4c4b..4c422b12 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/loading.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/loading.tsx @@ -3,5 +3,5 @@ import { LoadingScreen } from "@/components/portal/LoadingScreen"; export default function Loading() { - return ; + return ; } diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx index a4df966c..343cf9f4 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx @@ -8,6 +8,7 @@ import { useRouter, useParams, useSearchParams } from "next/navigation"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import Link from "next/link"; +import { Calendar, ChevronLeft, MapPin, Users } from "lucide-react"; // Extracted Tab Components import { InfoTab } from "@/components/hackathon/InfoTab"; @@ -15,6 +16,7 @@ import { ScheduleTab } from "@/components/hackathon/ScheduleTab"; import { ProjectsTab } from "@/components/hackathon/ProjectsTab"; import { ResultsTab } from "@/components/hackathon/ResultsTab"; import { TeamsTab } from "@/components/hackathon/TeamsTab"; +import { StatusBadge } from "@/components/hackathon/StatusBadge"; import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; import { decodeHackathonParam } from "@/lib/hackathon-slug"; @@ -39,74 +41,44 @@ function formatDateRange(start: Date | string, end: Date | string) { } function statusConfig(s: string) { - const map: Record< - string, - { - label: string; - dot: string; - text: string; - bg: string; - border: string; - glow: string; - } - > = { + const map: Record = { draft: { - label: "Not Published", - dot: "bg-white/40", - text: "text-[var(--text-primary)]/60", - bg: "bg-white/5", - border: "border-[var(--border-subtle)]", - glow: "", + label: "Not published", + color: + "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", + }, + announced: { + label: "Opens soon", + color: + "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", }, open: { - label: "Registering", - dot: "bg-emerald-400", - text: "text-accent", - bg: "bg-accent/10", - border: "border-accent/30", - glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]", + label: "Registration open", + color: "text-accent bg-accent/10 border-accent/25", }, in_progress: { - label: "Live Now", - dot: "bg-emerald-400", - text: "text-accent", - bg: "bg-accent/10", - border: "border-accent/30", - glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]", + label: "Live", + color: "text-accent bg-accent/10 border-accent/25", }, completed: { label: "Completed", - dot: "bg-white/40", - text: "text-[var(--text-primary)]/60", - bg: "bg-white/5", - border: "border-[var(--border-subtle)]", - glow: "", + color: + "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", }, closed: { - label: "Applications Closed", - dot: "bg-amber-400", - text: "text-amber-400", - bg: "bg-amber-400/10", - border: "border-amber-400/30", - glow: "", + label: "Registration closed", + color: "text-amber-400 bg-amber-400/10 border-amber-400/30", }, cancelled: { label: "Cancelled", - dot: "bg-rose-500", - text: "text-rose-500", - bg: "bg-rose-500/10", - border: "border-rose-500/30", - glow: "", + color: "text-rose-500 bg-rose-500/10 border-rose-500/30", }, }; return ( map[s] ?? { - label: s, - dot: "bg-gray-500", - text: "text-[var(--text-muted)]", - bg: "bg-gray-500/10", - border: "border-gray-500/20", - glow: "", + label: s.replace(/_/g, " "), + color: + "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", } ); } @@ -114,10 +86,10 @@ function statusConfig(s: string) { type TabType = "INFO" | "SCHEDULE" | "PROJECTS" | "TEAMS" | "RESULTS"; const TABS: { id: TabType; label: string }[] = [ - { id: "INFO", label: "Info & Register" }, - { id: "SCHEDULE", label: "Schedule & QR" }, - { id: "PROJECTS", label: "Project Gallery" }, - { id: "TEAMS", label: "Find Teams" }, + { id: "INFO", label: "Overview" }, + { id: "SCHEDULE", label: "Schedule & pass" }, + { id: "PROJECTS", label: "Projects" }, + { id: "TEAMS", label: "Teams" }, { id: "RESULTS", label: "Results" }, ]; @@ -160,7 +132,7 @@ export default function HackathonDetailPage() { }, [authStatus, router]); if (authStatus === "loading" || isLoading) - return ; + return ; if (!session) return null; // A hackathon can be missing (bad link, deleted event) or hidden. Without // this branch the loading guard below never clears and the page spins @@ -172,154 +144,62 @@ export default function HackathonDetailPage() { const conf = statusConfig(hackathon.status); return ( -
- {/* Animated Ambient Background Glows */} -
-
+
+
+
+
+
-
+
-
- - - -
- All Hackathons + + All hackathons {/* Header Card */} - - {/* Header Background Gradient Overlay */} -
- {/* Subtle Border Glow. group-hover, not hover: the card content sits - above this layer at z-10, so a bare hover: never fired. */} -
- -
- {/* Status Badge */} -
+
+ -
- - {conf.label} - -
+ {conf.label} +
- {/* Registration Indicator */} - {isRegistered && ( -
- - - - - Registered - -
- )} + {myReg && } - {/* Theme */} {hackathon.theme && ( - + {hackathon.theme} )}
-

+

{hackathon.name}

-
-
-
- - - -
- - {formatDateRange(hackathon.startDate, hackathon.endDate)} - +
+
+ + {formatDateRange(hackathon.startDate, hackathon.endDate)}
{hackathon.location && ( -
-
- - - -
- {hackathon.location} +
+ + {hackathon.location}
)} {hackathon.maxParticipants && ( -
-
- - - -
- - {hackathon.currentParticipants} / {hackathon.maxParticipants}{" "} - Spots +
+ + + {hackathon.currentParticipants} of {hackathon.maxParticipants}{" "} + spots taken
)} @@ -330,7 +210,7 @@ export default function HackathonDetailPage() {
{TABS.map((t) => ( @@ -358,7 +237,6 @@ export default function HackathonDetailPage() { role="tabpanel" id={`panel-${tab}`} aria-labelledby={`tab-${tab}`} - className="relative z-10" > {tab === "INFO" ? ( )} diff --git a/sites/mainweb/app/(portal)/hackathons/error.tsx b/sites/mainweb/app/(portal)/hackathons/error.tsx index 30d52327..b8053561 100644 --- a/sites/mainweb/app/(portal)/hackathons/error.tsx +++ b/sites/mainweb/app/(portal)/hackathons/error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { useChunkErrorRecovery } from "@/lib/chunk-error"; @@ -19,51 +20,37 @@ export default function Error({ }, [error]); return ( -
+
- -
- - - -
+ + diff --git a/sites/mainweb/app/(portal)/hackathons/loading.tsx b/sites/mainweb/app/(portal)/hackathons/loading.tsx index 92a95d1b..357b7a54 100644 --- a/sites/mainweb/app/(portal)/hackathons/loading.tsx +++ b/sites/mainweb/app/(portal)/hackathons/loading.tsx @@ -3,7 +3,5 @@ import { LoadingScreen } from "@/components/portal/LoadingScreen"; export default function Loading() { - return ( - - ); + return ; } diff --git a/sites/mainweb/app/(portal)/hackathons/page.tsx b/sites/mainweb/app/(portal)/hackathons/page.tsx index c0a2f816..9c669171 100644 --- a/sites/mainweb/app/(portal)/hackathons/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/page.tsx @@ -9,14 +9,15 @@ import { useRouter } from "next/navigation"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import Link from "next/link"; +import { StatusBadge } from "@/components/hackathon/StatusBadge"; import { Calendar, FolderGit2, Globe, - LayoutGrid, ArrowRight, - FileCode2, ChevronLeft, + MapPin, + Users, } from "lucide-react"; type HackathonStatus = @@ -49,82 +50,43 @@ function formatDateRange(start: Date | string, end: Date | string) { } function statusConfig(s: HackathonStatus | "draft" | "cancelled") { - const map: Record< - string, - { - label: string; - dot: string; - text: string; - bg: string; - border: string; - glow: string; - } - > = { + const map: Record = { announced: { - label: "Opening Soon", - dot: "bg-cyan-400", - text: "text-cyan-400", - bg: "bg-cyan-400/10", - border: "border-cyan-400/30", - glow: "", + label: "Opens soon", + className: "text-cyan-400 bg-cyan-400/10 border-cyan-400/25", }, open: { - label: "Registering", - dot: "bg-emerald-400", - text: "text-accent", - bg: "bg-accent/10", - border: "border-accent/30", - glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]", + label: "Registration open", + className: "text-accent bg-accent/10 border-accent/25", }, in_progress: { - label: "Live Now", - dot: "bg-emerald-400", - text: "text-accent", - bg: "bg-accent/10", - border: "border-accent/30", - glow: "shadow-[0_0_15px_rgba(52,211,153,0.6)]", + label: "Live", + className: "text-accent bg-accent/10 border-accent/25", }, completed: { label: "Completed", - dot: "bg-white/40", - text: "text-[var(--text-primary)]/60", - bg: "bg-white/5", - border: "border-[var(--border-subtle)]", - glow: "", + className: + "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", }, closed: { - label: "Applications Closed", - dot: "bg-amber-400", - text: "text-amber-400", - bg: "bg-amber-400/10", - border: "border-amber-400/30", - glow: "", + label: "Registration closed", + className: "text-amber-400 bg-amber-400/10 border-amber-400/25", }, cancelled: { label: "Cancelled", - dot: "bg-rose-500", - text: "text-rose-500", - bg: "bg-rose-500/10", - border: "border-rose-500/30", - glow: "", + className: "text-rose-500 bg-rose-500/10 border-rose-500/25", }, draft: { label: "Draft", - dot: "bg-gray-500", - text: "text-text-muted", - bg: "bg-gray-500/10", - border: "border-gray-500/20", - glow: "", + className: + "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", }, }; return ( map[s] ?? { label: s, - dot: "bg-gray-500", - text: "text-text-muted", - bg: "bg-gray-500/10", - border: "border-gray-500/20", - glow: "", + className: + "text-[var(--text-muted)] bg-[var(--bg-secondary)] border-[var(--border-subtle)]", } ); } @@ -166,60 +128,77 @@ export default function HackathonsPage() { }, [authStatus, router]); if (authStatus === "loading" || isLoading) - return ; + return ; if (!session) return null; - const registeredIds = new Set(myRegs?.map((r) => r.hackathonId) ?? []); + const regStatusById = new Map( + myRegs?.map((r) => [r.hackathonId, r.registrationStatus]) ?? [], + ); const tabs = [ - { id: "browse", label: "Browse Events", icon: LayoutGrid }, - { id: "registrations", label: "My Registrations", icon: Calendar }, - { id: "projects", label: "My Projects", icon: FolderGit2 }, + { id: "browse", label: "Browse" }, + { id: "registrations", label: "My registrations" }, + { id: "projects", label: "My projects" }, ]; + const filters = [ + { key: "all", label: "All" }, + { key: "open", label: "Open" }, + { key: "live", label: "Live" }, + { key: "completed", label: "Past" }, + ] as const; + + const filtered = (hackathons ?? []).filter((h) => { + if (statusFilter === "all") return true; + if (statusFilter === "open") return h.status === "open"; + if (statusFilter === "live") return h.status === "in_progress"; + return h.status === "completed"; + }); + const filterLabel = + filters.find((f) => f.key === statusFilter)?.label.toLowerCase() ?? ""; + return ( -
- {/* Ambient Background Glows */} -
-
+
+ {/* Ambient glows */} +
+
+
+
-
+
{/* Back nav */} -
- -
- Club Portal + + Dashboard {/* Header */} -
-

- Hackathon Hub +
+

+ Hackathons

-

- Browse upcoming competitions, manage your registrations, and - showcase your project portfolio. +

+ Events you can register for, your registrations, and projects + you've submitted.

{/* Tab Navigation */} -
+
{tabs.map((tab) => ( setActiveTab(tab.id as Tab)} - className={`flex-1 min-w-[120px] flex items-center justify-center gap-2 px-4 py-3 rounded-none text-sm font-semibold transition-ui ${ + className={`rounded-sm px-5 py-2 text-sm font-bold uppercase tracking-wider transition-colors ${ activeTab === tab.id - ? "bg-accent/10 text-accent border border-accent/30" - : "text-text-muted hover:text-[var(--text-primary)] hover:bg-white/5" + ? "bg-accent/15 text-accent" + : "text-[var(--text-muted)] hover:text-[var(--text-primary)]" }`} > - {tab.label} ))} @@ -227,22 +206,17 @@ export default function HackathonsPage() { {/* ── BROWSE TAB ── */} {activeTab === "browse" && ( -
+
{/* Status Filter */} -
- {[ - { key: "all", label: "All" }, - { key: "open", label: "Registering" }, - { key: "live", label: "Live Now" }, - { key: "completed", label: "Completed" }, - ].map(({ key, label }) => ( +
+ {filters.map(({ key, label }) => (
{!hackathons || hackathons.length === 0 ? ( - -
- - - + +
+ +
+

+ No hackathons are listed yet. New events show up here when + they're announced. +

+
+ ) : filtered.length === 0 ? ( + +
+
-

- No Active Hackathons -

-

- There are currently no hackathons available. Check back soon. +

+ No {filterLabel} hackathons right now.

) : ( -
- {hackathons - .filter((h) => { - if (statusFilter === "all") return true; - if (statusFilter === "open") return h.status === "open"; - if (statusFilter === "live") - return h.status === "in_progress"; - return h.status === "completed"; - }) - .map((h) => { - const conf = statusConfig(h.status); - const isRegistered = registeredIds.has(h.id); - return ( - + {filtered.map((h) => { + const conf = statusConfig(h.status); + const regStatus = regStatusById.get(h.id); + return ( + + - -
-
- -
-
-
- - {conf.label} - -
- {isRegistered && ( -
- - - - - Registered - -
- )} -
- -
- {h.theme && ( -

- {h.theme} -

- )} -

- {h.name} -

- {h.description && ( -

- {h.description} -

- )} -
- -
-
-
- - - -
- - {formatDateRange(h.startDate, h.endDate)} - -
- {h.location && ( -
-
- - - -
- - {h.location} - -
- )} - {h.maxParticipants && ( -
-
- - - -
- - {h.currentParticipants} /{" "} - {h.maxParticipants} Spots - -
- )} +
+ + {conf.label} + + {regStatus && } +
+ +
+ {h.theme && ( +

+ {h.theme} +

+ )} +

+ {h.name} +

+ {h.description && ( +

+ {h.description} +

+ )} +
+ +
+
+ + {formatDateRange(h.startDate, h.endDate)} +
+ {h.location && ( +
+ + {h.location}
- -
- - - + )} + {h.maxParticipants && ( +
+ + {h.currentParticipants} of {h.maxParticipants}{" "} + spots taken
-
- - - ); - })} + )} +
+ +
+ View +
+ + + ); + })}
)} {/* Judge Sign-Up CTA */} -
- -
-
-
-

- Want to be a Judge? -

-

- Help evaluate amazing projects, mentor emerging - developers, and be a core part of our hackathon community. - We're always looking for industry professionals and - experienced students to join our judging panels. -

-
- - Apply to Judge - -
- -
+ +
+

+ Judge a hackathon +

+

+ Industry professionals and experienced students score + projects at the expo. You apply per event, and an organiser + reviews each application. +

+
+ + Apply to judge + +
)} {/* ── MY REGISTRATIONS TAB ── */} {activeTab === "registrations" && ( -
+
{myRegs && myRegs.length > 0 ? ( -
+
{myRegs.map((reg) => ( -
- -
- -
- - {reg.registrationStatus} - -
- -

- {reg.hackathon.name} -

- {reg.hackathon.theme && ( -

- Theme: {reg.hackathon.theme} -

- )} + +
+ +
-

- {reg.hackathon.description} -

+
+

+ {reg.hackathon.name} +

+ {reg.hackathon.theme && ( +

+ Theme: {reg.hackathon.theme} +

+ )} +

+ {reg.hackathon.description} +

+
-
- - - {reg.hackathon.startDate - ? new Date( - reg.hackathon.startDate, - ).toLocaleDateString() - : "TBA"} - - - Enter Portal{" "} - - -
+
+ + + {reg.hackathon.startDate + ? new Date( + reg.hackathon.startDate, + ).toLocaleDateString() + : "TBA"} + + + View + +
+
))}
) : ( -
-
- + +
+
-

- No active registrations -

-

- You haven't registered for any upcoming hackathons yet. Browse - events to get started. +

+ You haven't registered for any hackathons yet.

-
+ )}
)} {/* ── MY PROJECTS TAB ── */} {activeTab === "projects" && ( -
+
{projects.length === 0 ? ( -
-
- + +
+
-

- No projects submitted -

-

- Join a hackathon team and submit a project to see your - portfolio grow here. +

+ Projects you submit to a hackathon show up here.

-
+ ) : ( -
+
{projects.map((project) => ( -
-
+ +
+

+ {project.name} +

+ + {project.status === "winner" + ? "Winner" + : project.status === "judging" + ? "In judging" + : "Submitted"} + +
+

+ {project.description} +

-
-
-

- {project.name} -

- + {project.githubUrl && ( + - {project.status} - -
-

- {project.description} -

- -
- {project.githubUrl && ( - - Source Code - - )} - {project.demoUrl && ( - - Live Demo - - )} -
+ Code + + )} + {project.demoUrl && ( + + Demo + + )}
-
+ ))}
)} diff --git a/sites/mainweb/app/(portal)/hacklytics/layout.tsx b/sites/mainweb/app/(portal)/hacklytics/layout.tsx index b8e219b9..749312c5 100644 --- a/sites/mainweb/app/(portal)/hacklytics/layout.tsx +++ b/sites/mainweb/app/(portal)/hacklytics/layout.tsx @@ -5,7 +5,7 @@ const SITE = "https://datasciencegt.org"; const URL = `${SITE}/hacklytics`; /** Same funnel statuses the page itself renders from. */ -const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "in_progress"] as const; +const PUBLIC_FUNNEL_STATUSES = ["announced", "open", "closed", "in_progress"] as const; /** * The edition this page is about, read on the server so the title, the link diff --git a/sites/mainweb/app/(portal)/hacklytics/page.tsx b/sites/mainweb/app/(portal)/hacklytics/page.tsx index 065f07f3..7a48ad3a 100644 --- a/sites/mainweb/app/(portal)/hacklytics/page.tsx +++ b/sites/mainweb/app/(portal)/hacklytics/page.tsx @@ -68,7 +68,7 @@ export default function HacklyticsPage() { @@ -83,7 +83,7 @@ export default function HacklyticsPage() { return (
-

+

Nothing announced yet

@@ -104,26 +104,23 @@ export default function HacklyticsPage() { return (

-
- - - {registrationOpen - ? "Registration is open" - : event.status === "in_progress" - ? "The hackathon is under way" - : event.status === "open" - ? "Registration has closed" - : "Registration opens soon"} - -
+

+ {registrationOpen + ? "Registration is open" + : event.status === "in_progress" + ? "The hackathon is under way" + : event.status === "open" || event.status === "closed" + ? "Registration has closed" + : "Registration opens soon"} +

-

+

{event.name}

-
+
When
@@ -132,7 +129,7 @@ export default function HacklyticsPage() {
{event.location ? (
-
+
Where
@@ -142,7 +139,7 @@ export default function HacklyticsPage() { ) : null} {event.theme ? (
-
+
Theme
{event.theme}
@@ -159,26 +156,47 @@ export default function HacklyticsPage() {
{registrationOpen ? (
-

+

Registration is open

{event.registrationDeadline - ? `Applications close ${formatDeadline(event.registrationDeadline)}. Spots are limited.` - : "Spots are limited and applications are reviewed as they arrive."} + ? `Applications close ${formatDeadline(event.registrationDeadline)}.` + : "Applications are reviewed as they arrive."}

Register now
- ) : ( + ) : event.status === "announced" ? ( + ) : ( + // The interest list only takes sign-ups before registration opens; + // offering it afterwards ended in an error on submit. +
+

+ {event.status === "in_progress" + ? "Happening now" + : "Registration has closed"} +

+

+ {event.status === "in_progress" + ? "Registered hackers can find their schedule, team and check-in pass on the event page." + : "If you applied, your status is on the event page."} +

+ + Go to the event page + +
)}
diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx index 9ad176b8..dc609500 100644 --- a/sites/mainweb/app/(portal)/judge/page.tsx +++ b/sites/mainweb/app/(portal)/judge/page.tsx @@ -9,6 +9,7 @@ import { useIsClient } from "@/lib/use-is-client"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import Link from "next/link"; +import { Calendar, ChevronLeft, Gavel, MapPin, Users } from "lucide-react"; type HackathonData = { id: string; @@ -23,6 +24,11 @@ type HackathonData = { theme?: string | null; }; +const BADGE = + "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[11px] font-bold uppercase tracking-wider border"; +const NEUTRAL_BADGE = + "bg-[var(--bg-secondary)] border-[var(--border-subtle)] text-[var(--text-muted)]"; + export default function JudgePage() { const { data: session, status } = useSession(); const router = useRouter(); @@ -33,14 +39,14 @@ export default function JudgePage() { const { data: hackathons, isLoading: hackathonsLoading } = trpc.hackathon.list.useQuery({}); // Applications, approved or not. getMyAssignments only returns approved ones, - // so without this an applicant saw "Apply to Judge" again — and pressing it + // so without this an applicant saw "Apply to judge" again — and pressing it // threw "You have already applied". const { data: applications } = trpc.judge.myApplications.useQuery(undefined, { enabled: !!session, }); // Gated on any approved application, not isJudge: isJudge answers for the // current edition only, while assignments span every edition, so a judge - // approved for another one saw "Application pending review" forever. + // approved for another one saw "Awaiting approval" forever. const { data: assignments } = trpc.judge.getMyAssignments.useQuery( undefined, { @@ -57,39 +63,37 @@ export default function JudgePage() { }, [status, router]); if (!mounted || status === "loading" || checkingJudge || hackathonsLoading) { - return ; + return ; } if (!session) return null; - const confMap: Record = - { - open: { - label: "Accepting Judges", - bg: "bg-accent/10", - border: "border-accent/30", - }, - in_progress: { - label: "Active", - bg: "bg-accent/10", - border: "border-accent/30", - }, - completed: { - label: "Completed", - bg: "bg-white/5", - border: "border-[var(--border-subtle)]", - }, - closed: { - label: "Closed", - bg: "bg-amber-400/10", - border: "border-amber-400/30", - }, - cancelled: { - label: "Cancelled", - bg: "bg-rose-500/10", - border: "border-rose-500/30", - }, - }; + const confMap: Record = { + announced: { + label: "Accepting judges", + cls: "bg-accent/10 border-accent/25 text-accent", + }, + open: { + label: "Accepting judges", + cls: "bg-accent/10 border-accent/25 text-accent", + }, + in_progress: { + label: "Live", + cls: "bg-accent/10 border-accent/25 text-accent", + }, + completed: { + label: "Completed", + cls: NEUTRAL_BADGE, + }, + closed: { + label: "Closed", + cls: "bg-amber-400/10 border-amber-400/30 text-amber-300", + }, + cancelled: { + label: "Cancelled", + cls: "bg-rose-500/10 border-rose-500/30 text-rose-400", + }, + }; const formatDateRange = (start: Date | string, end: Date | string) => { const s = new Date(start); @@ -104,191 +108,150 @@ export default function JudgePage() { }; return ( -
-
-
-
- -
- - - -
- Nexus - -

- Judge Portal -

-

- Browse upcoming hackathons and apply to become a judge. You must - be approved by the hackathon admin before you can start judging. -

-
+
+
+
+
+
+ +
+
+ + + Dashboard + +

+ Judging +

+

+ Apply to judge an event. An organiser approves you before you can + score. +

-
- {hackathons?.map((h: HackathonData) => { - // draft has no entry, and admins see drafts in this list. - const conf = confMap[h.status] ?? { - label: h.status, - bg: "bg-white/5", - border: "border-[var(--border-subtle)]", - }; - const assignment = assignments?.find((a) => a.hackathonId === h.id); - const isRegistered = !!assignment; - const application = applications?.find( - (a) => a.hackathonId === h.id, - ); - const awaitingApproval = !isRegistered && !!application; + {!hackathons?.length ? ( + +
+ +
+

+ No hackathons are taking judge applications right now. +

+
+ ) : ( +
+ {hackathons.map((h: HackathonData) => { + // draft has no entry, and admins see drafts in this list. + const conf = confMap[h.status] ?? { + label: h.status, + cls: NEUTRAL_BADGE, + }; + const assignment = assignments?.find( + (a) => a.hackathonId === h.id, + ); + const isRegistered = !!assignment; + const application = applications?.find( + (a) => a.hackathonId === h.id, + ); + const awaitingApproval = !isRegistered && !!application; - return ( - - {/* The card is a plain container, not a Link. - It used to wrap the whole thing in an anchor and then put - the "Apply to Judge" / "Ready to Judge" links inside it — - nested anchors, which React warns about and browsers - resolve unpredictably: the inner link often lost its click - to the outer one, so Apply took you to the judging screen - instead of the application form. */} -
-
-
+ {/* The card is a plain container, not a Link. + It used to wrap the whole thing in an anchor and then put + the "Apply to judge" / "Start judging" links inside it — + nested anchors, which React warns about and browsers + resolve unpredictably: the inner link often lost its click + to the outer one, so Apply took you to the judging screen + instead of the application form. */} +
+ {conf.label} +
+
+ {h.theme && ( +

+ {h.theme} +

+ )} +

-
- {conf.label} + {h.name} +

+ {h.description && ( +

+ {h.description} +

+ )} +
+
+
+ + {formatDateRange(h.startDate, h.endDate)}
-
- {h.theme && ( -

- {h.theme} -

- )} -

+ + {h.location} +

+ )} + {h.maxParticipants && ( +
+ + + {h.currentParticipants ?? 0} of {h.maxParticipants}{" "} + spots taken + +
+ )} +
+
+ {isRegistered ? ( + - {h.name} -

- {h.description && ( -

- {h.description} -

- )} -
-
-
- - - - {formatDateRange(h.startDate, h.endDate)} + Start judging + + ) : awaitingApproval ? ( +
+ Awaiting approval
- {h.location && ( -
- - - - {h.location} -
- )} - {h.maxParticipants && ( -
- - - - - {h.currentParticipants ?? 0} / {h.maxParticipants}{" "} - Spots - -
- )} -
-
- {isRegistered ? ( - <> - - Ready to Judge - - - ) : awaitingApproval ? ( -
- Application pending review -
- ) : h.status === "open" || - h.status === "closed" || - h.status === "in_progress" ? ( - - Apply to Judge - - ) : ( - - )} -
+ ) : h.status === "announced" || + h.status === "open" || + h.status === "closed" || + h.status === "in_progress" ? ( + + Apply to judge + + ) : ( + + )}
-
- - ); - })} -
+ + ); + })} +
+ )}
); diff --git a/sites/mainweb/app/(portal)/judge/register/page.tsx b/sites/mainweb/app/(portal)/judge/register/page.tsx index e82a2645..69a34d11 100644 --- a/sites/mainweb/app/(portal)/judge/register/page.tsx +++ b/sites/mainweb/app/(portal)/judge/register/page.tsx @@ -18,7 +18,7 @@ import { } from "@/components/hackathon/FormComponents"; import { SHIRT_SIZES, DIETARY_OPTIONS } from "@/components/hackathon/constants"; import Link from "next/link"; -import { ChevronLeft } from "lucide-react"; +import { Check, ChevronLeft } from "lucide-react"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; const JUDGE_REGISTRATION_STEPS = [ @@ -78,14 +78,16 @@ export default function JudgeRegisterPage() { return ; } - if (isLoading) return ; + if (isLoading) return ; // `closed` belongs here: closing participant registration is the natural step // *before* recruiting judges, and leaving it out emptied this page with no // explanation exactly when organisers were sending the link out. const activeHackathons = hackathons?.filter( + // Announced too: judges are recruited before registration opens. (h) => + h.status === "announced" || h.status === "open" || h.status === "closed" || h.status === "in_progress", @@ -158,37 +160,22 @@ export default function JudgeRegisterPage() { if (success) { return ( -
-
- -
- - - -
-

- Application Submitted +
+ + +

+ Application received

-

- Thank you for applying to be a judge! Our organizing team will - review your application and assign you to a track shortly. You will - be notified via email once approved. + {/* judge.setActive sends the approval email on the transition. */} +

+ An organiser will review it and assign you a track. You'll get + an email once you're approved.

- Return to Hub + Back to hackathons
@@ -196,37 +183,36 @@ export default function JudgeRegisterPage() { } return ( -
-
-
- -
- -
- -
- Back to Hub - +
+
+
+
+
-
-

- Judge Application +
+
+ + + Back to hackathons + +

+ Apply to judge

-

- Join the mentor network. Help evaluate cutting-edge projects and - guide the next generation of builders. +

+ Pick an event, tell us about yourself, and choose a track. Takes + about five minutes.

- + {step === 0 && ( -

+

Which event would you like to judge for?

@@ -239,18 +225,18 @@ export default function JudgeRegisterPage() { @@ -305,7 +291,7 @@ export default function JudgeRegisterPage() { placeholder="(555) 123-4567" /> setSpecialty(e.target.value)} placeholder="e.g. AI, Web3, Design, Product" @@ -364,7 +350,7 @@ export default function JudgeRegisterPage() { allowDeselect /> ) : ( -

+

This hackathon has no tracks published yet — organisers will assign yours.

diff --git a/sites/mainweb/app/(portal)/login/page.tsx b/sites/mainweb/app/(portal)/login/page.tsx index dd9588e5..476b9c5e 100644 --- a/sites/mainweb/app/(portal)/login/page.tsx +++ b/sites/mainweb/app/(portal)/login/page.tsx @@ -6,9 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { usePortalContext } from "@/lib/use-portal-context"; import { safeCallback } from "@/lib/safe-callback"; import { useIsClient } from "@/lib/use-is-client"; - -// DSGT Query - Premium Landing Page -// Ultra-modern, standout UI/UX +import { LiquidGlass } from "@/components/portal/LiquidGlass"; export default function Home() { const { data: session, status } = useSession(); @@ -90,7 +88,7 @@ export default function Home() { if (!res?.ok || res.error) { setEmailSending(false); setEmailError( - "We could not send that link. Check the address and try again.", + "We couldn't send a code to that address. Check it and try again.", ); return; } @@ -105,7 +103,7 @@ export default function Home() { router.push(`/verify?email=${encodeURIComponent(email)}${next}`); } catch { setEmailSending(false); - setEmailError("We could not send that link. Please try again."); + setEmailError("We couldn't send the code. Try again."); } }; @@ -117,104 +115,71 @@ export default function Home() { signIn("github", { callbackUrl: callbackUrl ?? "/dashboard" }); }; - if (!mounted) - return ( -
- ); + if (!mounted) return
; const isRedirecting = !!session; + const busy = emailSending || isRedirecting || status === "loading"; - return ( -
- {/* Animated Background */} -
- {/* Gradient orbs */} -
-
-
- - {/* Grid pattern */} -
+ const btnPrimary = + "w-full flex items-center justify-center gap-2 px-6 py-3 bg-accent text-[var(--text-on-accent)] rounded-sm font-bold text-sm uppercase tracking-widest hover:bg-[var(--accent-secondary)] transition-ui disabled:opacity-50 disabled:cursor-not-allowed"; + const btnSecondary = + "w-full flex items-center justify-center gap-2 px-5 py-2.5 rounded-sm border border-[var(--border-medium)] bg-[var(--bg-secondary)] text-[var(--text-muted)] hover:bg-[var(--bg-elevated)] hover:text-[var(--text-primary)] hover:border-[var(--border-hover)] transition-ui text-xs font-bold uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"; - {/* Floating particles */} -
- - {/* Animated scanline */} -
+ return ( +
+
+
+
- {/* Main Content */} -
-
- {/* Left Panel - Hero */} -
- {/* Status Badge */} -
-
-
- - Query Engine - - - - v.2.1.0 - -
-
- - {/* Hero Title */} -
-

- Query
- - DSGT. - +
+
+
+ DSGT Logo +
+

+ DS@GT +

+

+ Query

-
- - {/* Subtitle */} -
-
-
- - Georgia Tech Data Science Community - -
-

- The collective intelligence of Georgia Tech's largest data - science community. Connect with peers, discover events, and - contribute to groundbreaking research. +

+ The member portal for Data Science at Georgia Tech: events, + hackathons, and your membership in one place.

+
- {/* CTA Buttons */} -
+ +

+ Sign in +

+

+ {isRedirecting + ? "You're signed in. Taking you to the portal…" + : "Use your Google or GitHub account, or get a code by email."} +

+ +
{hasProvider("github") && ( )} @@ -232,159 +197,53 @@ export default function Home() { onClick={() => { setShowEmailInput(true); }} - disabled={ - emailSending || isRedirecting || status === "loading" - } - className="w-full sm:w-auto px-8 py-6 border border-[var(--border-subtle)] text-[var(--text-primary)] font-black text-[11px] uppercase tracking-[0.2em] rounded-sm hover:bg-white/5 hover:border-white/20 transition-ui active:scale-95 disabled:opacity-30" + disabled={busy} + className={btnSecondary} > - Email Login + Email me a code ) : ( -
-
- { - setEmail(e.target.value); - setEmailError(""); - }} - onKeyDown={(e) => e.key === "Enter" && handleEmailLogin()} - placeholder="your@email.com" - aria-label="Email address" - disabled={emailSending || emailSent} - className="flex-1 sm:w-56 px-5 py-6 bg-[var(--bg-primary)]/60 border border-[var(--border-subtle)] text-[var(--text-primary)] font-mono text-[11px] rounded-sm focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/20 placeholder:text-gray-600 disabled:opacity-30 transition-ui" - /> - + Email + +
+ { + setEmail(e.target.value); + setEmailError(""); + }} + onKeyDown={(e) => e.key === "Enter" && handleEmailLogin()} + placeholder="you@gatech.edu" + aria-label="Email address" + disabled={emailSending || emailSent} + className="w-full min-w-0 flex-1 bg-[var(--bg-secondary)] border border-[var(--border-subtle)] rounded-sm px-4 py-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-subtle)] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-ui disabled:opacity-50" + /> +
{emailError && ( -

- {emailError} -

+

{emailError}

)}
)}
-
- - {/* Right Panel - Visual */} -
- {/* Background glow */} -
- - {/* Central visual */} -
- {/* Rotating rings */} -
-
-
- - {/* Central content */} -
- DSGT Logo -
- - {/* Status indicator */} -
-

- {isRedirecting - ? "Handshake Verified" - : status === "loading" - ? "Synchronizing…" - : "Core Operational"} -

-
- -
- - {status.toUpperCase()} - - - -
- REGION: ATL-08 - - -
- JUDGE: {portalContext?.isJudge ? "ACTIVE" : "OPEN"} - -
-
- - {/* Decorative element */} -
-
-
-
+
- - {/* Footer */} -
-
-
Internal Terminal // Auth Gateway v2.1
-
- - ACCESS NODE: 0812-ATL -
-
-
- - {/* Global styles for custom animations */} -

+ Name + Email + Status + Check-In Time