From 217a50674e6347bff7d1fdb7576a6b29dece28ad Mon Sep 17 00:00:00 2001 From: aamoghS Date: Fri, 6 Nov 2026 09:43:00 -0500 Subject: [PATCH 1/6] perf(mainweb): hand the portal session and sidebar queries to the client with the HTML A signed-in page waited for JS, then asked /api/auth/session (twice), then sent a tRPC batch for the portal context and registrations before it could draw. The layout now reads the session and those two queries on the server and passes them to SessionProvider and a HydrationBoundary, so the session and sidebar reads need no client round trips. Visitors without a session cookie skip it. Dashboard content visible (cold, 5 runs, 1440px): median 592 ms to 391 ms, range 506..740 to 269..471. --- sites/mainweb/app/(portal)/layout.tsx | 7 ++- sites/mainweb/app/(portal)/providers.tsx | 23 ++++++-- sites/mainweb/lib/portal-bootstrap.server.ts | 57 ++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 sites/mainweb/lib/portal-bootstrap.server.ts diff --git a/sites/mainweb/app/(portal)/layout.tsx b/sites/mainweb/app/(portal)/layout.tsx index 373fe337..34ca0a5d 100644 --- a/sites/mainweb/app/(portal)/layout.tsx +++ b/sites/mainweb/app/(portal)/layout.tsx @@ -4,6 +4,7 @@ export const revalidate = 0; // Disable ISR for authenticated pages import { preload } from "react-dom"; import { Providers } from "./providers"; +import { loadPortalBootstrap } from "@/lib/portal-bootstrap.server"; import "./fonts.generated.css"; import "./liquid-glass.css"; import PortalWrapper from "@/components/portal/PortalWrapper"; @@ -14,7 +15,7 @@ import { FONT_PRELOADS } from "@/lib/fonts.generated"; // stylesheet sets their variables on :root, so modals portaled to // resolve the font roles in liquid-glass.css too. -export default function PortalLayout({ +export default async function PortalLayout({ children, }: { children: React.ReactNode; @@ -23,8 +24,10 @@ export default function PortalLayout({ preload(href, { as: "font", type: "font/woff2", crossOrigin: "" }), ); + const { session, state } = await loadPortalBootstrap(); + return ( - +
{children}
diff --git a/sites/mainweb/app/(portal)/providers.tsx b/sites/mainweb/app/(portal)/providers.tsx index 89a60197..5d37d9fd 100644 --- a/sites/mainweb/app/(portal)/providers.tsx +++ b/sites/mainweb/app/(portal)/providers.tsx @@ -2,7 +2,12 @@ import type { ReactNode } from "react"; import { SessionProvider } from "next-auth/react"; -import { QueryClientProvider } from "@tanstack/react-query"; +import { + HydrationBoundary, + QueryClientProvider, +} from "@tanstack/react-query"; +import type { DehydratedState } from "@tanstack/react-query"; +import type { Session } from "next-auth"; import { httpBatchLink } from "@trpc/client"; import { useEffect, useState } from "react"; import superjson from "superjson"; @@ -39,7 +44,15 @@ function AppThemeProvider({ ); } -export function Providers({ children }: { children: React.ReactNode }) { +export function Providers({ + children, + session, + state, +}: { + children: React.ReactNode; + session?: Session; + state?: DehydratedState; +}) { const [queryClient] = useState(() => createAppQueryClient()); const [trpcClient] = useState(() => @@ -55,9 +68,11 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - + - {children} + + {children} + diff --git a/sites/mainweb/lib/portal-bootstrap.server.ts b/sites/mainweb/lib/portal-bootstrap.server.ts new file mode 100644 index 00000000..83e0d112 --- /dev/null +++ b/sites/mainweb/lib/portal-bootstrap.server.ts @@ -0,0 +1,57 @@ +import { cookies, headers } from "next/headers"; +import { dehydrate, QueryClient } from "@tanstack/react-query"; +import type { Session } from "next-auth"; +import { appRouter, createContext, resolveClientIp } from "@query/api"; + +/** + * What every signed-in portal page needs before it can draw: who is signed in + * and the two reads the sidebar makes. Fetched here, once, and handed to the + * client providers, so the browser neither asks /api/auth/session for the + * session nor makes a tRPC round trip for the portal context after hydration. + * + * Visitors with no session cookie (the login page, guest judges, the public + * hacklytics page) skip all of it and mount exactly as they did before. + */ +export async function loadPortalBootstrap() { + const jar = await cookies(); + const hasSessionCookie = jar + .getAll() + .some((c) => c.name.endsWith("authjs.session-token")); + if (!hasSessionCookie) return { session: undefined, state: undefined }; + + const ctx = await createContext({ + clientIp: resolveClientIp((await headers()).get("x-forwarded-for")), + }); + if (!ctx.session?.user) return { session: undefined, state: undefined }; + + const caller = appRouter.createCaller(ctx); + const queryClient = new QueryClient(); + + // Keys match what trpc..useQuery builds on the client. + const portal = await caller.user.getPortalContext().catch(() => null); + if (portal) { + queryClient.setQueryData([["user", "getPortalContext"], { type: "query" }], portal); + } + if (portal && !portal.isAdmin && !portal.isBugTester) { + const regs = await caller.hackathon.myRegistrations().catch(() => null); + if (regs) { + queryClient.setQueryData( + [["hackathon", "myRegistrations"], { type: "query" }], + regs, + ); + } + } + + // Only what the browser's own session carries, never the session token. + const { user, expires } = ctx.session; + const session: Session = { + expires: expires ?? "", + user: { + id: user?.id, + name: user?.name, + email: user?.email, + image: user?.image, + } as Session["user"], + }; + return { session, state: dehydrate(queryClient) }; +} From 97effec61602e0b6f41d5980494346f240b7f726 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Fri, 6 Nov 2026 09:52:04 -0500 Subject: [PATCH 2/6] perf(mainweb): prefetch the first reads of the busiest portal pages on the server Dashboard, club, checkin, settings and judge each drew only after hydration plus one tRPC batch. A server page now runs those reads and hands them to the unchanged client page through a HydrationBoundary, so the batch request is gone. Cold load to h1 visible (5 runs, 1440px, base vs perf, median): dashboard 446 to 266 ms, club 490 to 304, checkin 354 to 237, settings 366 to 306, judge 521 to 459. Text, redirects and theme match the base on all five at 402 and 1440 wide. --- .../app/(portal)/checkin/CheckinPage.tsx | 85 +++ sites/mainweb/app/(portal)/checkin/page.tsx | 91 +-- sites/mainweb/app/(portal)/club/ClubPage.tsx | 364 +++++++++++ sites/mainweb/app/(portal)/club/page.tsx | 374 +---------- .../app/(portal)/dashboard/DashboardPage.tsx | 594 +++++++++++++++++ sites/mainweb/app/(portal)/dashboard/page.tsx | 600 +----------------- .../mainweb/app/(portal)/judge/JudgePage.tsx | 196 ++++++ sites/mainweb/app/(portal)/judge/page.tsx | 204 +----- .../app/(portal)/settings/SettingsPage.tsx | 518 +++++++++++++++ sites/mainweb/app/(portal)/settings/page.tsx | 525 +-------------- .../components/portal/PrefetchedPage.tsx | 29 + sites/mainweb/lib/portal-bootstrap.server.ts | 91 ++- 12 files changed, 1907 insertions(+), 1764 deletions(-) create mode 100644 sites/mainweb/app/(portal)/checkin/CheckinPage.tsx create mode 100644 sites/mainweb/app/(portal)/club/ClubPage.tsx create mode 100644 sites/mainweb/app/(portal)/dashboard/DashboardPage.tsx create mode 100644 sites/mainweb/app/(portal)/judge/JudgePage.tsx create mode 100644 sites/mainweb/app/(portal)/settings/SettingsPage.tsx create mode 100644 sites/mainweb/components/portal/PrefetchedPage.tsx diff --git a/sites/mainweb/app/(portal)/checkin/CheckinPage.tsx b/sites/mainweb/app/(portal)/checkin/CheckinPage.tsx new file mode 100644 index 00000000..4588f3f2 --- /dev/null +++ b/sites/mainweb/app/(portal)/checkin/CheckinPage.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { useEventCheckIn } from "@/components/portal/EventCheckIn"; +import { trpc } from "@/lib/trpc"; +import { + body, + btnPrimary, + itemTitle, + label, + page, + pageDek, + pageTitle, +} from "@/components/portal/ui"; + +/** + * Event check-in for anyone signed in. The Club Portal has the same scanner, + * but it is a members' page; attendance is not, so this route takes everyone. + */ +export default function CheckInPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const checkIn = useEventCheckIn(); + + const { data: myStats } = trpc.events.myStats.useQuery(undefined, { + enabled: !!session, + }); + + // Straight back here after signing in: the QR is on the wall in front of + // them, and a detour through the dashboard loses it. + useEffect(() => { + if (status === "unauthenticated") { + router.push(`/login?callbackUrl=${encodeURIComponent("/checkin")}`); + } + }, [status, router]); + + if (status === "loading" || !session) { + return ; + } + + const total = myStats?.totalEvents ?? 0; + + return ( +
+ {checkIn.modals} + +
+

Check in to an event

+

+ Scan the QR code shown at the event. Open to everyone, no membership + needed. +

+
+ +
+

At an event?

+

+ Point your camera at the event's QR code to record your + attendance. +

+ +
+ +
+

Your check-ins

+

+ + {total} + + {total === 1 ? "event" : "events"} +

+
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/checkin/page.tsx b/sites/mainweb/app/(portal)/checkin/page.tsx index 4588f3f2..f374b000 100644 --- a/sites/mainweb/app/(portal)/checkin/page.tsx +++ b/sites/mainweb/app/(portal)/checkin/page.tsx @@ -1,85 +1,14 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { useEventCheckIn } from "@/components/portal/EventCheckIn"; -import { trpc } from "@/lib/trpc"; -import { - body, - btnPrimary, - itemTitle, - label, - page, - pageDek, - pageTitle, -} from "@/components/portal/ui"; - -/** - * Event check-in for anyone signed in. The Club Portal has the same scanner, - * but it is a members' page; attendance is not, so this route takes everyone. - */ -export default function CheckInPage() { - const { data: session, status } = useSession(); - const router = useRouter(); - const checkIn = useEventCheckIn(); - - const { data: myStats } = trpc.events.myStats.useQuery(undefined, { - enabled: !!session, - }); - - // Straight back here after signing in: the QR is on the wall in front of - // them, and a detour through the dashboard loses it. - useEffect(() => { - if (status === "unauthenticated") { - router.push(`/login?callbackUrl=${encodeURIComponent("/checkin")}`); - } - }, [status, router]); - - if (status === "loading" || !session) { - return ; - } - - const total = myStats?.totalEvents ?? 0; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import CheckinPage from "./CheckinPage"; +export default function Page() { return ( -
- {checkIn.modals} - -
-

Check in to an event

-

- Scan the QR code shown at the event. Open to everyone, no membership - needed. -

-
- -
-

At an event?

-

- Point your camera at the event's QR code to record your - attendance. -

- -
- -
-

Your check-ins

-

- - {total} - - {total === 1 ? "event" : "events"} -

-
-
+ [ + ["events.myStats", undefined, c.events.myStats()], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/club/ClubPage.tsx b/sites/mainweb/app/(portal)/club/ClubPage.tsx new file mode 100644 index 00000000..ec980cde --- /dev/null +++ b/sites/mainweb/app/(portal)/club/ClubPage.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { loginHref } from "@/lib/safe-callback"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { MemberPassCard } from "@/components/portal/MemberPassCard"; +import { useEventCheckIn } from "@/components/portal/EventCheckIn"; +import { trpc } from "@/lib/trpc"; +import { useSession } from "next-auth/react"; +import { QrCode } from "lucide-react"; +import { + body, + btnPrimary, + itemTitle, + label, + mastRule, + meta, + page, + pageDek, + pageTitle, + sectionTitle, + status as statusText, + tab as tabClass, + tabList, + textLink, +} from "@/components/portal/ui"; + +type Tab = "general" | "history" | "status"; + +const eventWhen = (date: Date | string) => + new Date(date).toLocaleString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + +const statusRow = + "grid grid-cols-1 gap-1 border-b border-[var(--border-subtle)] py-3.5 sm:grid-cols-[200px_1fr] sm:gap-6"; + +export default function ClubPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const checkIn = useEventCheckIn(); + + const { data: userData } = trpc.user.me.useQuery(undefined, { + enabled: !!session, + }); + const { data: memberStatus } = trpc.member.checkStatus.useQuery(undefined, { + enabled: !!session, + }); + const { data: myStats } = trpc.events.myStats.useQuery(undefined, { + enabled: !!session, + }); + const { data: myEvents } = trpc.events.myEvents.useQuery(undefined, { + enabled: !!session, + }); + // Members could see what they had already attended but never what was + // coming, so the portal gave no reason to open it between meetings. + const { data: allEvents } = trpc.events.list.useQuery(undefined, { + enabled: !!session, + }); + + // Sessions are for enrolled members and are listed on /club/bootcamp. + const upcomingEvents = (allEvents ?? []) + .filter((event) => event.kind === "club") + // oxlint-disable-next-line react/purity + .filter((event) => new Date(event.eventDate) >= new Date()) + .sort( + (a, b) => + new Date(a.eventDate).getTime() - new Date(b.eventDate).getTime(), + ) + .slice(0, 5); + + const [activeTab, setActiveTab] = useState("general"); + + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.replace("#", "") as Tab; + if (["general", "history", "status"].includes(hash)) { + setActiveTab(hash); + } + }; + handleHashChange(); + window.addEventListener("hashchange", handleHashChange); + return () => window.removeEventListener("hashchange", handleHashChange); + }, []); + + useEffect(() => { + if (status === "unauthenticated") { + router.push(loginHref()); + } else if ( + status === "authenticated" && + memberStatus && + !memberStatus.isMember + ) { + router.push("/dashboard"); + } + }, [status, memberStatus, router]); + + if (status === "loading" || !memberStatus) { + return ; + } + + if (!session || !memberStatus.isMember) return null; + + const firstName = userData?.name?.split(" ")[0] || "Member"; + const nextEvent = upcomingEvents[0]; + + const tabs: { id: Tab; label: string }[] = [ + { id: "general", label: "Event check-in" }, + { id: "history", label: "Attendance history" }, + { id: "status", label: "Membership status" }, + ]; + + return ( +
+ {checkIn.modals} + +
+ {/* Masthead */} +
+
+ {userData?.image ? ( + Avatar + ) : ( +
+ {firstName.charAt(0)} +
+ )} + + {userData?.name || "Member"} + + Member +
+ + Main dashboard + +
+ + {/* Headline: the next thing to show up for, or how check-in works. */} +
+

+ {nextEvent ? `Next up: ${nextEvent.title}` : "Check in at the door"} +

+

+ {nextEvent + ? `${eventWhen(nextEvent.eventDate)}${nextEvent.location ? ` · ${nextEvent.location}` : ""}. Scan the event code or show your pass when you arrive.` + : "Nothing is on the calendar right now. At the next meeting, scan its code or show your pass to record attendance."} +

+

+ Looking for hackathons? Registrations, teams and projects live in + the{" "} + + hackathon hub + + . +

+
+ + {/* Tab Navigation */} + + + {/* Tab Content */} +
+ {/* General Check-In */} + {activeTab === "general" && ( +
+
+

Event check-in

+

+ Scan the QR code displayed at the entrance of general club + meetings to record your attendance. +

+ +
+ + + +
+
+

Upcoming events

+ {upcomingEvents.length === 0 ? ( +

+ Nothing scheduled yet. New events show up here as soon + as they are posted. +

+ ) : ( +
    + {upcomingEvents.map((event) => ( +
  • +

    + {event.title} +

    +

    + {eventWhen(event.eventDate)} + {event.location ? ` · ${event.location}` : ""} +

    +
  • + ))} +
+ )} +
+ +
+

Quick guide

+
    +
  • + General gatherings require QR check-ins for attendance. +
  • +
  • + Hackathons have their own separate hub with registration + and team tools. +
  • +
+
+
+
+ )} + + {/* Attendance History */} + {activeTab === "history" && ( +
+

Attendance history

+ {!myEvents || myEvents.length === 0 ? ( +
+

+ You haven't checked into any events yet. When you do, + they will appear here. +

+ setActiveTab("general")} + className={`${textLink} mt-4`} + > + Go to event check-in + +
+ ) : ( +
    + {myEvents.map((checkIn) => ( +
  • +
    +

    + {checkIn.event.title} +

    +

    + {checkIn.event.kind === "bootcamp" + ? "Bootcamp · " + : ""} + {new Date(checkIn.checkedInAt).toLocaleDateString()} + {checkIn.event.location + ? ` · ${checkIn.event.location}` + : ""} +

    +
    + + Verified + +
  • + ))} +
+ )} +
+ )} + + {/* Membership Status */} + {activeTab === "status" && ( +
+

Membership

+

+ You have full access to the club portal, hackathons, and member + resources. +

+
+
+
Membership
+
+ Valid +
+
+
+
Account tier
+
+ Verified member +
+
+
+
Valid through
+
+ {memberStatus?.expiresAt + ? new Date(memberStatus.expiresAt).toLocaleDateString() + : "N/A"} +
+
+
+
+ )} +
+
+
+ ); +} diff --git a/sites/mainweb/app/(portal)/club/page.tsx b/sites/mainweb/app/(portal)/club/page.tsx index ec980cde..5b8f4889 100644 --- a/sites/mainweb/app/(portal)/club/page.tsx +++ b/sites/mainweb/app/(portal)/club/page.tsx @@ -1,364 +1,18 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { loginHref } from "@/lib/safe-callback"; -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { MemberPassCard } from "@/components/portal/MemberPassCard"; -import { useEventCheckIn } from "@/components/portal/EventCheckIn"; -import { trpc } from "@/lib/trpc"; -import { useSession } from "next-auth/react"; -import { QrCode } from "lucide-react"; -import { - body, - btnPrimary, - itemTitle, - label, - mastRule, - meta, - page, - pageDek, - pageTitle, - sectionTitle, - status as statusText, - tab as tabClass, - tabList, - textLink, -} from "@/components/portal/ui"; - -type Tab = "general" | "history" | "status"; - -const eventWhen = (date: Date | string) => - new Date(date).toLocaleString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); - -const statusRow = - "grid grid-cols-1 gap-1 border-b border-[var(--border-subtle)] py-3.5 sm:grid-cols-[200px_1fr] sm:gap-6"; - -export default function ClubPage() { - const { data: session, status } = useSession(); - const router = useRouter(); - const checkIn = useEventCheckIn(); - - const { data: userData } = trpc.user.me.useQuery(undefined, { - enabled: !!session, - }); - const { data: memberStatus } = trpc.member.checkStatus.useQuery(undefined, { - enabled: !!session, - }); - const { data: myStats } = trpc.events.myStats.useQuery(undefined, { - enabled: !!session, - }); - const { data: myEvents } = trpc.events.myEvents.useQuery(undefined, { - enabled: !!session, - }); - // Members could see what they had already attended but never what was - // coming, so the portal gave no reason to open it between meetings. - const { data: allEvents } = trpc.events.list.useQuery(undefined, { - enabled: !!session, - }); - - // Sessions are for enrolled members and are listed on /club/bootcamp. - const upcomingEvents = (allEvents ?? []) - .filter((event) => event.kind === "club") - // oxlint-disable-next-line react/purity - .filter((event) => new Date(event.eventDate) >= new Date()) - .sort( - (a, b) => - new Date(a.eventDate).getTime() - new Date(b.eventDate).getTime(), - ) - .slice(0, 5); - - const [activeTab, setActiveTab] = useState("general"); - - useEffect(() => { - const handleHashChange = () => { - const hash = window.location.hash.replace("#", "") as Tab; - if (["general", "history", "status"].includes(hash)) { - setActiveTab(hash); - } - }; - handleHashChange(); - window.addEventListener("hashchange", handleHashChange); - return () => window.removeEventListener("hashchange", handleHashChange); - }, []); - - useEffect(() => { - if (status === "unauthenticated") { - router.push(loginHref()); - } else if ( - status === "authenticated" && - memberStatus && - !memberStatus.isMember - ) { - router.push("/dashboard"); - } - }, [status, memberStatus, router]); - - if (status === "loading" || !memberStatus) { - return ; - } - - if (!session || !memberStatus.isMember) return null; - - const firstName = userData?.name?.split(" ")[0] || "Member"; - const nextEvent = upcomingEvents[0]; - - const tabs: { id: Tab; label: string }[] = [ - { id: "general", label: "Event check-in" }, - { id: "history", label: "Attendance history" }, - { id: "status", label: "Membership status" }, - ]; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import ClubPage from "./ClubPage"; +export default function Page() { return ( -
- {checkIn.modals} - -
- {/* Masthead */} -
-
- {userData?.image ? ( - Avatar - ) : ( -
- {firstName.charAt(0)} -
- )} - - {userData?.name || "Member"} - - Member -
- - Main dashboard - -
- - {/* Headline: the next thing to show up for, or how check-in works. */} -
-

- {nextEvent ? `Next up: ${nextEvent.title}` : "Check in at the door"} -

-

- {nextEvent - ? `${eventWhen(nextEvent.eventDate)}${nextEvent.location ? ` · ${nextEvent.location}` : ""}. Scan the event code or show your pass when you arrive.` - : "Nothing is on the calendar right now. At the next meeting, scan its code or show your pass to record attendance."} -

-

- Looking for hackathons? Registrations, teams and projects live in - the{" "} - - hackathon hub - - . -

-
- - {/* Tab Navigation */} - - - {/* Tab Content */} -
- {/* General Check-In */} - {activeTab === "general" && ( -
-
-

Event check-in

-

- Scan the QR code displayed at the entrance of general club - meetings to record your attendance. -

- -
- - - -
-
-

Upcoming events

- {upcomingEvents.length === 0 ? ( -

- Nothing scheduled yet. New events show up here as soon - as they are posted. -

- ) : ( -
    - {upcomingEvents.map((event) => ( -
  • -

    - {event.title} -

    -

    - {eventWhen(event.eventDate)} - {event.location ? ` · ${event.location}` : ""} -

    -
  • - ))} -
- )} -
- -
-

Quick guide

-
    -
  • - General gatherings require QR check-ins for attendance. -
  • -
  • - Hackathons have their own separate hub with registration - and team tools. -
  • -
-
-
-
- )} - - {/* Attendance History */} - {activeTab === "history" && ( -
-

Attendance history

- {!myEvents || myEvents.length === 0 ? ( -
-

- You haven't checked into any events yet. When you do, - they will appear here. -

- setActiveTab("general")} - className={`${textLink} mt-4`} - > - Go to event check-in - -
- ) : ( -
    - {myEvents.map((checkIn) => ( -
  • -
    -

    - {checkIn.event.title} -

    -

    - {checkIn.event.kind === "bootcamp" - ? "Bootcamp · " - : ""} - {new Date(checkIn.checkedInAt).toLocaleDateString()} - {checkIn.event.location - ? ` · ${checkIn.event.location}` - : ""} -

    -
    - - Verified - -
  • - ))} -
- )} -
- )} - - {/* Membership Status */} - {activeTab === "status" && ( -
-

Membership

-

- You have full access to the club portal, hackathons, and member - resources. -

-
-
-
Membership
-
- Valid -
-
-
-
Account tier
-
- Verified member -
-
-
-
Valid through
-
- {memberStatus?.expiresAt - ? new Date(memberStatus.expiresAt).toLocaleDateString() - : "N/A"} -
-
-
-
- )} -
-
-
+ [ + ["user.me", undefined, c.user.me()], + ["member.checkStatus", undefined, c.member.checkStatus()], + ["events.myStats", undefined, c.events.myStats()], + ["events.myEvents", undefined, c.events.myEvents()], + ["events.list", undefined, c.events.list()], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/dashboard/DashboardPage.tsx b/sites/mainweb/app/(portal)/dashboard/DashboardPage.tsx new file mode 100644 index 00000000..4ca40bd2 --- /dev/null +++ b/sites/mainweb/app/(portal)/dashboard/DashboardPage.tsx @@ -0,0 +1,594 @@ +"use client"; + +import { useSession, signOut } from "next-auth/react"; +import { loginHref } from "@/lib/safe-callback"; +import { trpc } from "@/lib/trpc"; +import { + useInvalidatePortalContext, + usePortalContext, +} from "@/lib/use-portal-context"; +import { hackathonSlug } from "@/lib/hackathon-slug"; +import { + hackathonNextStep, + isWithinEditionDates, + nextStepQueries, + projectSubmitted, +} from "@/lib/hackathon-next-step"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } 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 { NextStepStrip } from "@/components/hackathon/NextStepStrip"; +import { + MEMBERSHIP_CENTS, + SEMESTER_MEMBERSHIP_CENTS, + formatCents, +} from "@query/api/pricing"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { ArrowRight } from "lucide-react"; +import { + body, + btnPrimary, + btnSecondary, + itemTitle, + label, + mastRule, + meta, + page, + pageDek, + pageTitle, + sectionRule, + sectionTitle, + status as statusLine, + tab, + tabList, + textLink, +} from "@/components/portal/ui"; + +export default function Dashboard() { + const { data: session, status } = useSession(); + const router = useRouter(); + + const { data: portalContext } = usePortalContext(); + const { data: userData } = trpc.user.me.useQuery(undefined, { + enabled: !!session, + }); + const { data: myRegs, isLoading: loadingRegs } = + trpc.hackathon.myRegistrations.useQuery(undefined, { + enabled: !!session, + // An accepted hacker is waiting on the door scan. While an event runs, + // re-read so the headline flips to checked in without a reload. + refetchInterval: (query) => { + const startOfDay = (d: Date | string) => + new Date(d).setHours(0, 0, 0, 0); + const today = startOfDay(new Date()); + return query.state.data?.some( + (r) => + r.registrationStatus === "approved" && + startOfDay(r.hackathon.startDate) <= today && + today <= startOfDay(r.hackathon.endDate), + ) + ? 30_000 + : false; + }, + }); + + const memberStatus = portalContext?.member; + const isAdmin = portalContext?.isAdmin ?? false; + const isJudge = portalContext?.isJudge ?? false; + + /** + * Which half they land on. Held as null until they choose so the default can + * follow the data once it arrives — a paid member opens on Club, everyone + * else on Hackathon, which is the half that is open to them. + */ + const [chosenView, setChosenView] = useState<"club" | "hackathon" | null>( + null, + ); + // oxlint-disable-next-line react/purity + const now = new Date(); + // Somebody accepted to an event that is on today lands on the hackathon + // half, member or not: that is the half with their next step. + const eventReg = myRegs?.find( + (r) => + (r.registrationStatus === "approved" || + r.registrationStatus === "checked_in") && + r.hackathon.status !== "cancelled" && + isWithinEditionDates(r.hackathon, now), + ); + const view = + chosenView ?? + (eventReg ? "hackathon" : memberStatus?.isMember ? "club" : "hackathon"); + const setView = setChosenView; + + const activeRegs = + myRegs?.filter((r) => + r.hackathon.endDate ? new Date(r.hackathon.endDate) >= now : true, + ) ?? []; + const pastRegs = + myRegs?.filter((r) => + r.hackathon.endDate ? new Date(r.hackathon.endDate) < now : false, + ) ?? []; + + // The event they are at, else the latest registration still on the + // calendar. Its next step reads up to four more queries, each held back + // until the step can use it, so a guest, or a hacker whose event has not + // begun, pays for none of them. + const latestReg = eventReg ?? activeRegs[0]; + const stepHackathonId = latestReg?.hackathonId ?? ""; + const stepOn = view === "hackathon" && !!latestReg; + const needs = latestReg + ? nextStepQueries({ + now, + hackathon: latestReg.hackathon, + registrationStatus: latestReg.registrationStatus, + }) + : { submission: false, judging: false, results: false }; + const submissionWindow = trpc.team.submissionWindow.useQuery( + { hackathonId: stepHackathonId }, + { enabled: stepOn && needs.submission }, + ); + const mySubmission = trpc.team.mySubmission.useQuery( + { hackathonId: stepHackathonId }, + { enabled: stepOn && needs.submission }, + ); + const itinerary = trpc.judging.hacker.itinerary.useQuery( + { hackathonId: stepHackathonId }, + { enabled: stepOn && needs.judging }, + ); + const results = trpc.hackathon.getResults.useQuery( + { hackathonId: stepHackathonId }, + { enabled: stepOn && needs.results }, + ); + // Held back until they answer, so the strip never shows a step it is about + // to take back ("Submit" to somebody who already has). + const stepLoading = + submissionWindow.isLoading || + mySubmission.isLoading || + itinerary.isLoading || + results.isLoading; + const nextStep = latestReg + ? hackathonNextStep({ + now, + hackathon: latestReg.hackathon, + registrationStatus: latestReg.registrationStatus, + hasTeam: !!latestReg.teamId, + submissionWindow: submissionWindow.data, + submitted: projectSubmitted(mySubmission.data), + itinerary: itinerary.data, + resultsPublished: (results.data?.length ?? 0) > 0, + }) + : null; + + // A link made here changes membership, so the page has to re-read it — or it + // keeps showing the pay button to somebody who has already paid. + const utils = trpc.useUtils(); + const invalidatePortalContext = useInvalidatePortalContext(); + const { mutate: attemptAutoLink } = trpc.stripe.attemptAutoLink.useMutation({ + onSuccess: (data) => { + if (!data.success) return; + void utils.member.checkStatus.invalidate(); + invalidatePortalContext(); + }, + }); + // Once per visit. `session` changes identity on every refetch (window focus), + // and each re-fire past the first only hits the server's 10s throttle. + const autoLinkFired = useRef(false); + useEffect(() => { + if (status !== "authenticated" || autoLinkFired.current) return; + autoLinkFired.current = true; + attemptAutoLink(); + }, [status, attemptAutoLink]); + useEffect(() => { + if (status === "unauthenticated") router.push(loginHref()); + }, [status, router]); + useEffect(() => { + if (isAdmin) router.replace("/admin"); + }, [isAdmin, router]); + + if (status === "loading") + return ; + if (!session) return null; + + const roleLabel = isAdmin + ? "Admin" + : memberStatus?.isMember + ? "Member" + : "Guest"; + + const today = now.toLocaleDateString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + }); + + const daysLeft = memberStatus?.daysRemaining; + const endsSoon = typeof daysLeft === "number" && daysLeft <= 30; + const expiresOn = memberStatus?.expiresAt + ? new Date(memberStatus.expiresAt).toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }) + : null; + + // The headline is the one thing that matters on this half right now, worked + // out from what the page already loaded. + let headline: string; + let dek: string; + if (view === "hackathon") { + if (loadingRegs) { + headline = "Your hackathons"; + dek = "Checking your registrations…"; + } else if (latestReg) { + const name = latestReg.hackathon.name; + const byStatus: Record = { + approved: `You're in for ${name}.`, + checked_in: `You're checked in at ${name}.`, + pending: `Your application to ${name} is under review.`, + waitlisted: `You're on the waitlist for ${name}.`, + rejected: `Your application to ${name} wasn't accepted.`, + }; + headline = + byStatus[latestReg.registrationStatus] ?? + `You're registered for ${name}.`; + dek = latestReg.team + ? `You're on team ${latestReg.team.name}.` + : "You don't have a team yet."; + } else { + headline = "Pick a hackathon to register for."; + dek = + "Hackathons are open to anyone with an account. No membership needed."; + } + } else if (memberStatus?.isMember) { + if (endsSoon) { + headline = `Your membership ends in ${daysLeft} ${daysLeft === 1 ? "day" : "days"}.`; + dek = + "Renew now and the new term starts when this one ends, so you lose nothing by paying early."; + } else { + headline = expiresOn + ? `You're a member through ${expiresOn}.` + : "Your membership is active."; + dek = "Club events, check-ins and member resources are open to you."; + } + } else if (memberStatus?.hasLapsed) { + headline = "Your membership has run out."; + dek = "Renew below to get back into the Club Portal."; + } else { + headline = "Become a member to open the Club Portal."; + dek = `${formatCents(MEMBERSHIP_CENTS)} a year or ${formatCents(SEMESTER_MEMBERSHIP_CENTS)} a semester. Projects stay open to browse either way.`; + } + + return ( +
+ {/* ── MASTHEAD ───────────────────────────────────── */} +
+
+ Avatar +

+ + {userData?.name ?? "Your account"} + + {userData?.email ? ` · ${userData.email}` : ""} · {roleLabel} +

+
+

+ {today} +

+
+ + {/* ── HEADLINE ───────────────────────────────────── */} +
+

{headline}

+

{dek}

+ {view === "hackathon" && !loadingRegs && !stepLoading && latestReg && ( +
+ {nextStep ? ( + + ) : ( + + Open {latestReg.hackathon.name} + + )} +
+ )} +
+ + {/* ── CLUB / HACKATHON ───────────────────────────── */} + {/* Two different things this org does, and they have different rules: + the hackathon is open to anyone with an account, the club is the + paid yearly membership. Splitting them is what stops the dashboard + reading as though everything is behind the same paywall. */} +
+ {( + [ + { value: "hackathon", label: "Hackathon" }, + { value: "club", label: "Club" }, + ] as const + ).map((option) => ( + + ))} +
+ + {/* ── MY HACKATHONS ───────────────────────────────── */} + {/* Straight under the tabs, whose hairline is the divider. */} +
+
+

My hackathons

+ + Browse all + +
+ + {loadingRegs ? ( + + ) : activeRegs.length > 0 ? ( +
    + {activeRegs.map((reg) => ( +
  • + +
    +
    +

    + {reg.hackathon.name} +

    + +
    +

    + {reg.hackathon.theme + ? `Theme: ${reg.hackathon.theme} · ` + : ""} + {reg.team ? `Team: ${reg.team.name}` : "No team yet"} +

    +
    + + View + + +
  • + ))} +
+ ) : ( +
+

+ Hackathons you register for will show up here. +

+ + Browse hackathons + +
+ )} + + {/* Past events */} + {!loadingRegs && pastRegs.length > 0 && ( +
+

Past events

+
    + {pastRegs.map((reg) => ( +
  • + + + {reg.hackathon.name} + + + + Details + + +
  • + ))} +
+
+ )} +
+ + {/* ── MEMBERSHIP ─────────────────────────────────── */} + {/* Club view only, and it now also catches lapsed members: `isMember` + means paid AND unexpired, so the renew path is reachable instead of + being hidden behind the same flag that expired. */} + {view === "club" && memberStatus?.isMember && ( +
+

Membership active

+

+ {expiresOn ? `Runs until ${expiresOn}` : "Active"} + {endsSoon ? ` · ${daysLeft} days left` : ""} +

+ {/* Renewing early extends from the current end date rather than + from today, so nobody loses time by paying ahead. */} + {endsSoon && ( +
+ +
+ )} +
+ )} + + {/* ── DESTINATIONS ───────────────────────────────── */} + {/* The hackathon half has no entry of its own: "Browse all" above leads + to the hub. It lists only the portals this person can open. */} + {(view === "club" || + isJudge || + isAdmin || + !!portalContext?.isBugTester) && ( +
    + {/* Club Portal — members only */} + {view === "club" && + (memberStatus?.isMember ? ( + + Club events, check-ins, and resources. + + ) : ( +
  • +

    Club Portal

    +

    + {memberStatus?.hasLapsed + ? "Your membership has run out. Renew below to get back in." + : "Membership required. Join below to unlock access."} +

    +
  • + ))} + + {/* Projects — club side, but browsing is open so anyone can see + what membership actually buys before paying for it. */} + {view === "club" && ( + + Projects the club runs year-round. Join one, or pitch your own. + + )} + + {/* Become a Member — sits beside Projects so the club view says + what is missing where the rest of the club lives. The pay UI is + the block below; this jumps to it. */} + {view === "club" && !memberStatus?.isMember && !isAdmin && ( + + {formatCents(MEMBERSHIP_CENTS)}/year or{" "} + {formatCents(SEMESTER_MEMBERSHIP_CENTS)}/semester. Opens the Club + Portal and member resources. + + )} + + {/* Judge Portal — judges only */} + {isJudge && ( + + Your judging editions, where you sit and the projects you score. + + )} + + {/* Admin Panel — admins, and bug testers read-only */} + {(isAdmin || !!portalContext?.isBugTester) && ( + + Manage events, attendees, and analytics. + + )} +
+ )} + + {/* The pay UI itself, below the destinations. The entry above is the + label; this is the block. */} + {view === "club" && !memberStatus?.isMember && !isAdmin && ( +
+

+ {memberStatus?.hasLapsed ? "Renew your membership" : "Become a member"} +

+
+ +
+
+ )} + + {/* ── SIGN OUT ───────────────────────────────────── */} +
+

+ Signed in{userData?.email ? ` as ${userData.email}` : ""}. +

+ +
+
+ ); +} + +/** + * One place the dashboard can send you: a serif title with an arrow and a + * line saying what is there. `plain` renders an in-page anchor rather than a + * route link. + */ +function Destination({ + href, + title, + plain = false, + children, +}: { + href: string; + title: string; + plain?: boolean; + children: ReactNode; +}) { + const content = ( + <> + + + {title} + + + {children} + + ); + return ( +
  • + {plain ? ( + + {content} + + ) : ( + + {content} + + )} +
  • + ); +} diff --git a/sites/mainweb/app/(portal)/dashboard/page.tsx b/sites/mainweb/app/(portal)/dashboard/page.tsx index 4ca40bd2..313d96c2 100644 --- a/sites/mainweb/app/(portal)/dashboard/page.tsx +++ b/sites/mainweb/app/(portal)/dashboard/page.tsx @@ -1,594 +1,14 @@ -"use client"; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import DashboardPage from "./DashboardPage"; -import { useSession, signOut } from "next-auth/react"; -import { loginHref } from "@/lib/safe-callback"; -import { trpc } from "@/lib/trpc"; -import { - useInvalidatePortalContext, - usePortalContext, -} from "@/lib/use-portal-context"; -import { hackathonSlug } from "@/lib/hackathon-slug"; -import { - hackathonNextStep, - isWithinEditionDates, - nextStepQueries, - projectSubmitted, -} from "@/lib/hackathon-next-step"; -import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import type { ReactNode } 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 { NextStepStrip } from "@/components/hackathon/NextStepStrip"; -import { - MEMBERSHIP_CENTS, - SEMESTER_MEMBERSHIP_CENTS, - formatCents, -} from "@query/api/pricing"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { ArrowRight } from "lucide-react"; -import { - body, - btnPrimary, - btnSecondary, - itemTitle, - label, - mastRule, - meta, - page, - pageDek, - pageTitle, - sectionRule, - sectionTitle, - status as statusLine, - tab, - tabList, - textLink, -} from "@/components/portal/ui"; - -export default function Dashboard() { - const { data: session, status } = useSession(); - const router = useRouter(); - - const { data: portalContext } = usePortalContext(); - const { data: userData } = trpc.user.me.useQuery(undefined, { - enabled: !!session, - }); - const { data: myRegs, isLoading: loadingRegs } = - trpc.hackathon.myRegistrations.useQuery(undefined, { - enabled: !!session, - // An accepted hacker is waiting on the door scan. While an event runs, - // re-read so the headline flips to checked in without a reload. - refetchInterval: (query) => { - const startOfDay = (d: Date | string) => - new Date(d).setHours(0, 0, 0, 0); - const today = startOfDay(new Date()); - return query.state.data?.some( - (r) => - r.registrationStatus === "approved" && - startOfDay(r.hackathon.startDate) <= today && - today <= startOfDay(r.hackathon.endDate), - ) - ? 30_000 - : false; - }, - }); - - const memberStatus = portalContext?.member; - const isAdmin = portalContext?.isAdmin ?? false; - const isJudge = portalContext?.isJudge ?? false; - - /** - * Which half they land on. Held as null until they choose so the default can - * follow the data once it arrives — a paid member opens on Club, everyone - * else on Hackathon, which is the half that is open to them. - */ - const [chosenView, setChosenView] = useState<"club" | "hackathon" | null>( - null, - ); - // oxlint-disable-next-line react/purity - const now = new Date(); - // Somebody accepted to an event that is on today lands on the hackathon - // half, member or not: that is the half with their next step. - const eventReg = myRegs?.find( - (r) => - (r.registrationStatus === "approved" || - r.registrationStatus === "checked_in") && - r.hackathon.status !== "cancelled" && - isWithinEditionDates(r.hackathon, now), - ); - const view = - chosenView ?? - (eventReg ? "hackathon" : memberStatus?.isMember ? "club" : "hackathon"); - const setView = setChosenView; - - const activeRegs = - myRegs?.filter((r) => - r.hackathon.endDate ? new Date(r.hackathon.endDate) >= now : true, - ) ?? []; - const pastRegs = - myRegs?.filter((r) => - r.hackathon.endDate ? new Date(r.hackathon.endDate) < now : false, - ) ?? []; - - // The event they are at, else the latest registration still on the - // calendar. Its next step reads up to four more queries, each held back - // until the step can use it, so a guest, or a hacker whose event has not - // begun, pays for none of them. - const latestReg = eventReg ?? activeRegs[0]; - const stepHackathonId = latestReg?.hackathonId ?? ""; - const stepOn = view === "hackathon" && !!latestReg; - const needs = latestReg - ? nextStepQueries({ - now, - hackathon: latestReg.hackathon, - registrationStatus: latestReg.registrationStatus, - }) - : { submission: false, judging: false, results: false }; - const submissionWindow = trpc.team.submissionWindow.useQuery( - { hackathonId: stepHackathonId }, - { enabled: stepOn && needs.submission }, - ); - const mySubmission = trpc.team.mySubmission.useQuery( - { hackathonId: stepHackathonId }, - { enabled: stepOn && needs.submission }, - ); - const itinerary = trpc.judging.hacker.itinerary.useQuery( - { hackathonId: stepHackathonId }, - { enabled: stepOn && needs.judging }, - ); - const results = trpc.hackathon.getResults.useQuery( - { hackathonId: stepHackathonId }, - { enabled: stepOn && needs.results }, - ); - // Held back until they answer, so the strip never shows a step it is about - // to take back ("Submit" to somebody who already has). - const stepLoading = - submissionWindow.isLoading || - mySubmission.isLoading || - itinerary.isLoading || - results.isLoading; - const nextStep = latestReg - ? hackathonNextStep({ - now, - hackathon: latestReg.hackathon, - registrationStatus: latestReg.registrationStatus, - hasTeam: !!latestReg.teamId, - submissionWindow: submissionWindow.data, - submitted: projectSubmitted(mySubmission.data), - itinerary: itinerary.data, - resultsPublished: (results.data?.length ?? 0) > 0, - }) - : null; - - // A link made here changes membership, so the page has to re-read it — or it - // keeps showing the pay button to somebody who has already paid. - const utils = trpc.useUtils(); - const invalidatePortalContext = useInvalidatePortalContext(); - const { mutate: attemptAutoLink } = trpc.stripe.attemptAutoLink.useMutation({ - onSuccess: (data) => { - if (!data.success) return; - void utils.member.checkStatus.invalidate(); - invalidatePortalContext(); - }, - }); - // Once per visit. `session` changes identity on every refetch (window focus), - // and each re-fire past the first only hits the server's 10s throttle. - const autoLinkFired = useRef(false); - useEffect(() => { - if (status !== "authenticated" || autoLinkFired.current) return; - autoLinkFired.current = true; - attemptAutoLink(); - }, [status, attemptAutoLink]); - useEffect(() => { - if (status === "unauthenticated") router.push(loginHref()); - }, [status, router]); - useEffect(() => { - if (isAdmin) router.replace("/admin"); - }, [isAdmin, router]); - - if (status === "loading") - return ; - if (!session) return null; - - const roleLabel = isAdmin - ? "Admin" - : memberStatus?.isMember - ? "Member" - : "Guest"; - - const today = now.toLocaleDateString(undefined, { - weekday: "long", - month: "long", - day: "numeric", - }); - - const daysLeft = memberStatus?.daysRemaining; - const endsSoon = typeof daysLeft === "number" && daysLeft <= 30; - const expiresOn = memberStatus?.expiresAt - ? new Date(memberStatus.expiresAt).toLocaleDateString(undefined, { - month: "long", - day: "numeric", - year: "numeric", - }) - : null; - - // The headline is the one thing that matters on this half right now, worked - // out from what the page already loaded. - let headline: string; - let dek: string; - if (view === "hackathon") { - if (loadingRegs) { - headline = "Your hackathons"; - dek = "Checking your registrations…"; - } else if (latestReg) { - const name = latestReg.hackathon.name; - const byStatus: Record = { - approved: `You're in for ${name}.`, - checked_in: `You're checked in at ${name}.`, - pending: `Your application to ${name} is under review.`, - waitlisted: `You're on the waitlist for ${name}.`, - rejected: `Your application to ${name} wasn't accepted.`, - }; - headline = - byStatus[latestReg.registrationStatus] ?? - `You're registered for ${name}.`; - dek = latestReg.team - ? `You're on team ${latestReg.team.name}.` - : "You don't have a team yet."; - } else { - headline = "Pick a hackathon to register for."; - dek = - "Hackathons are open to anyone with an account. No membership needed."; - } - } else if (memberStatus?.isMember) { - if (endsSoon) { - headline = `Your membership ends in ${daysLeft} ${daysLeft === 1 ? "day" : "days"}.`; - dek = - "Renew now and the new term starts when this one ends, so you lose nothing by paying early."; - } else { - headline = expiresOn - ? `You're a member through ${expiresOn}.` - : "Your membership is active."; - dek = "Club events, check-ins and member resources are open to you."; - } - } else if (memberStatus?.hasLapsed) { - headline = "Your membership has run out."; - dek = "Renew below to get back into the Club Portal."; - } else { - headline = "Become a member to open the Club Portal."; - dek = `${formatCents(MEMBERSHIP_CENTS)} a year or ${formatCents(SEMESTER_MEMBERSHIP_CENTS)} a semester. Projects stay open to browse either way.`; - } - - return ( -
    - {/* ── MASTHEAD ───────────────────────────────────── */} -
    -
    - Avatar -

    - - {userData?.name ?? "Your account"} - - {userData?.email ? ` · ${userData.email}` : ""} · {roleLabel} -

    -
    -

    - {today} -

    -
    - - {/* ── HEADLINE ───────────────────────────────────── */} -
    -

    {headline}

    -

    {dek}

    - {view === "hackathon" && !loadingRegs && !stepLoading && latestReg && ( -
    - {nextStep ? ( - - ) : ( - - Open {latestReg.hackathon.name} - - )} -
    - )} -
    - - {/* ── CLUB / HACKATHON ───────────────────────────── */} - {/* Two different things this org does, and they have different rules: - the hackathon is open to anyone with an account, the club is the - paid yearly membership. Splitting them is what stops the dashboard - reading as though everything is behind the same paywall. */} -
    - {( - [ - { value: "hackathon", label: "Hackathon" }, - { value: "club", label: "Club" }, - ] as const - ).map((option) => ( - - ))} -
    - - {/* ── MY HACKATHONS ───────────────────────────────── */} - {/* Straight under the tabs, whose hairline is the divider. */} -
    -
    -

    My hackathons

    - - Browse all - -
    - - {loadingRegs ? ( - - ) : activeRegs.length > 0 ? ( -
      - {activeRegs.map((reg) => ( -
    • - -
      -
      -

      - {reg.hackathon.name} -

      - -
      -

      - {reg.hackathon.theme - ? `Theme: ${reg.hackathon.theme} · ` - : ""} - {reg.team ? `Team: ${reg.team.name}` : "No team yet"} -

      -
      - - View - - -
    • - ))} -
    - ) : ( -
    -

    - Hackathons you register for will show up here. -

    - - Browse hackathons - -
    - )} - - {/* Past events */} - {!loadingRegs && pastRegs.length > 0 && ( -
    -

    Past events

    -
      - {pastRegs.map((reg) => ( -
    • - - - {reg.hackathon.name} - - - - Details - - -
    • - ))} -
    -
    - )} -
    - - {/* ── MEMBERSHIP ─────────────────────────────────── */} - {/* Club view only, and it now also catches lapsed members: `isMember` - means paid AND unexpired, so the renew path is reachable instead of - being hidden behind the same flag that expired. */} - {view === "club" && memberStatus?.isMember && ( -
    -

    Membership active

    -

    - {expiresOn ? `Runs until ${expiresOn}` : "Active"} - {endsSoon ? ` · ${daysLeft} days left` : ""} -

    - {/* Renewing early extends from the current end date rather than - from today, so nobody loses time by paying ahead. */} - {endsSoon && ( -
    - -
    - )} -
    - )} - - {/* ── DESTINATIONS ───────────────────────────────── */} - {/* The hackathon half has no entry of its own: "Browse all" above leads - to the hub. It lists only the portals this person can open. */} - {(view === "club" || - isJudge || - isAdmin || - !!portalContext?.isBugTester) && ( -
      - {/* Club Portal — members only */} - {view === "club" && - (memberStatus?.isMember ? ( - - Club events, check-ins, and resources. - - ) : ( -
    • -

      Club Portal

      -

      - {memberStatus?.hasLapsed - ? "Your membership has run out. Renew below to get back in." - : "Membership required. Join below to unlock access."} -

      -
    • - ))} - - {/* Projects — club side, but browsing is open so anyone can see - what membership actually buys before paying for it. */} - {view === "club" && ( - - Projects the club runs year-round. Join one, or pitch your own. - - )} - - {/* Become a Member — sits beside Projects so the club view says - what is missing where the rest of the club lives. The pay UI is - the block below; this jumps to it. */} - {view === "club" && !memberStatus?.isMember && !isAdmin && ( - - {formatCents(MEMBERSHIP_CENTS)}/year or{" "} - {formatCents(SEMESTER_MEMBERSHIP_CENTS)}/semester. Opens the Club - Portal and member resources. - - )} - - {/* Judge Portal — judges only */} - {isJudge && ( - - Your judging editions, where you sit and the projects you score. - - )} - - {/* Admin Panel — admins, and bug testers read-only */} - {(isAdmin || !!portalContext?.isBugTester) && ( - - Manage events, attendees, and analytics. - - )} -
    - )} - - {/* The pay UI itself, below the destinations. The entry above is the - label; this is the block. */} - {view === "club" && !memberStatus?.isMember && !isAdmin && ( -
    -

    - {memberStatus?.hasLapsed ? "Renew your membership" : "Become a member"} -

    -
    - -
    -
    - )} - - {/* ── SIGN OUT ───────────────────────────────────── */} -
    -

    - Signed in{userData?.email ? ` as ${userData.email}` : ""}. -

    - -
    -
    - ); -} - -/** - * One place the dashboard can send you: a serif title with an arrow and a - * line saying what is there. `plain` renders an in-page anchor rather than a - * route link. - */ -function Destination({ - href, - title, - plain = false, - children, -}: { - href: string; - title: string; - plain?: boolean; - children: ReactNode; -}) { - const content = ( - <> - - - {title} - - - {children} - - ); +export default function Page() { return ( -
  • - {plain ? ( - - {content} - - ) : ( - - {content} - - )} -
  • + [ + ["user.me", undefined, c.user.me()], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/judge/JudgePage.tsx b/sites/mainweb/app/(portal)/judge/JudgePage.tsx new file mode 100644 index 00000000..568d5462 --- /dev/null +++ b/sites/mainweb/app/(portal)/judge/JudgePage.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { ChevronRight } from "lucide-react"; +import { loginHref } from "@/lib/safe-callback"; +import { trpc } from "@/lib/trpc"; +import { useIsClient } from "@/lib/use-is-client"; +import { usePortalContext } from "@/lib/use-portal-context"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { + body, + itemTitle, + label, + meta, + page, + pageDek, + pageTitle, + sectionRule, + status, + textLink, +} from "@/components/portal/ui"; + +// Statuses that take judge applications; judge.register enforces the same. +const APPLYING = new Set(["announced", "open", "closed", "in_progress"]); + +function formatDates(start: Date | string, end: Date | string) { + const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" }; + const s = new Date(start); + const e = new Date(end); + return `${s.toLocaleDateString("en-US", opts)} – ${e.toLocaleDateString("en-US", { ...opts, year: "numeric" })}`; +} + +export default function JudgePage() { + const { data: session, status: authStatus } = useSession(); + const router = useRouter(); + const mounted = useIsClient(); + + // isJudge covers a judge an organiser added by email, who has no application; + // myApplications covers every edition the user applied to, approved or not. + const { data: judgeStatus, isLoading: checkingJudge } = + trpc.judge.isJudge.useQuery(undefined, { enabled: !!session }); + const { data: applications, isLoading: applicationsLoading } = + trpc.judge.myApplications.useQuery(undefined, { enabled: !!session }); + const { data: hackathons, isLoading: hackathonsLoading } = + trpc.hackathon.list.useQuery({ limit: 50 }); + // Staff can judge any edition without applying. + const { data: portal } = usePortalContext(); + + // The approval email points here, so a signed-out visitor goes to sign in. + useEffect(() => { + if (authStatus === "unauthenticated") router.push(loginHref()); + }, [authStatus, router]); + + if ( + !mounted || + authStatus === "loading" || + checkingJudge || + applicationsLoading || + hackathonsLoading + ) { + return ; + } + + if (!session) return null; + + const byId = new Map((hackathons ?? []).map((h) => [h.id, h])); + + const judgingIds = new Set( + (applications ?? []).filter((a) => a.approved).map((a) => a.hackathonId), + ); + if (judgeStatus?.isJudge && judgeStatus.hackathonId) { + judgingIds.add(judgeStatus.hackathonId); + } + if (portal?.isAdmin) { + for (const h of hackathons ?? []) { + if (h.status === "in_progress") judgingIds.add(h.id); + } + } + const waitingIds = new Set( + (applications ?? []) + .filter((a) => !a.approved && !judgingIds.has(a.hackathonId)) + .map((a) => a.hackathonId), + ); + const canApply = (hackathons ?? []).filter( + (h) => + APPLYING.has(h.status) && !judgingIds.has(h.id) && !waitingIds.has(h.id), + ); + + const judging = [...judgingIds]; + const waiting = [...waitingIds]; + + return ( +
    +
    +

    Judging

    +

    + {judging.length > 0 + ? "Open your edition on judging day, then scan the QR code at your room or table to take your seat." + : "Apply to judge an edition. An organiser approves you before judging day."} +

    +
    + + {judging.length > 0 && ( +
    +

    You are judging

    +
      + {judging.map((id) => { + const h = byId.get(id); + return ( +
    • + +
      +

      + {h?.name ?? "Hackathon"} +

      + {h && ( +

      + {formatDates(h.startDate, h.endDate)} + {h.location ? ` · ${h.location}` : ""} +

      + )} +
      +
    • + ); + })} +
    +
    + )} + + {waiting.length > 0 && ( +
    +

    Waiting for approval

    +
      + {waiting.map((id) => ( +
    • +

      + {byId.get(id)?.name ?? "Hackathon"} +

      + Applied +
    • + ))} +
    +

    + You will get an email when an organiser approves you. +

    +
    + )} + +
    +

    Taking judges

    + {canApply.length === 0 ? ( +

    + No edition is taking judge applications right now. +

    + ) : ( +
      + {canApply.map((h) => ( +
    • +
      +

      {h.name}

      +

      + {formatDates(h.startDate, h.endDate)} +

      +
      + + Apply to judge + +
    • + ))} +
    + )} +
    +
    + ); +} diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx index 568d5462..9cb17ba8 100644 --- a/sites/mainweb/app/(portal)/judge/page.tsx +++ b/sites/mainweb/app/(portal)/judge/page.tsx @@ -1,196 +1,16 @@ -"use client"; - -import { useEffect } from "react"; -import Link from "next/link"; -import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; -import { ChevronRight } from "lucide-react"; -import { loginHref } from "@/lib/safe-callback"; -import { trpc } from "@/lib/trpc"; -import { useIsClient } from "@/lib/use-is-client"; -import { usePortalContext } from "@/lib/use-portal-context"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import { - body, - itemTitle, - label, - meta, - page, - pageDek, - pageTitle, - sectionRule, - status, - textLink, -} from "@/components/portal/ui"; - -// Statuses that take judge applications; judge.register enforces the same. -const APPLYING = new Set(["announced", "open", "closed", "in_progress"]); - -function formatDates(start: Date | string, end: Date | string) { - const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" }; - const s = new Date(start); - const e = new Date(end); - return `${s.toLocaleDateString("en-US", opts)} – ${e.toLocaleDateString("en-US", { ...opts, year: "numeric" })}`; -} - -export default function JudgePage() { - const { data: session, status: authStatus } = useSession(); - const router = useRouter(); - const mounted = useIsClient(); - - // isJudge covers a judge an organiser added by email, who has no application; - // myApplications covers every edition the user applied to, approved or not. - const { data: judgeStatus, isLoading: checkingJudge } = - trpc.judge.isJudge.useQuery(undefined, { enabled: !!session }); - const { data: applications, isLoading: applicationsLoading } = - trpc.judge.myApplications.useQuery(undefined, { enabled: !!session }); - const { data: hackathons, isLoading: hackathonsLoading } = - trpc.hackathon.list.useQuery({ limit: 50 }); - // Staff can judge any edition without applying. - const { data: portal } = usePortalContext(); - - // The approval email points here, so a signed-out visitor goes to sign in. - useEffect(() => { - if (authStatus === "unauthenticated") router.push(loginHref()); - }, [authStatus, router]); - - if ( - !mounted || - authStatus === "loading" || - checkingJudge || - applicationsLoading || - hackathonsLoading - ) { - return ; - } - - if (!session) return null; - - const byId = new Map((hackathons ?? []).map((h) => [h.id, h])); - - const judgingIds = new Set( - (applications ?? []).filter((a) => a.approved).map((a) => a.hackathonId), - ); - if (judgeStatus?.isJudge && judgeStatus.hackathonId) { - judgingIds.add(judgeStatus.hackathonId); - } - if (portal?.isAdmin) { - for (const h of hackathons ?? []) { - if (h.status === "in_progress") judgingIds.add(h.id); - } - } - const waitingIds = new Set( - (applications ?? []) - .filter((a) => !a.approved && !judgingIds.has(a.hackathonId)) - .map((a) => a.hackathonId), - ); - const canApply = (hackathons ?? []).filter( - (h) => - APPLYING.has(h.status) && !judgingIds.has(h.id) && !waitingIds.has(h.id), - ); - - const judging = [...judgingIds]; - const waiting = [...waitingIds]; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; +import JudgePage from "./JudgePage"; +export default function Page() { return ( -
    -
    -

    Judging

    -

    - {judging.length > 0 - ? "Open your edition on judging day, then scan the QR code at your room or table to take your seat." - : "Apply to judge an edition. An organiser approves you before judging day."} -

    -
    - - {judging.length > 0 && ( -
    -

    You are judging

    -
      - {judging.map((id) => { - const h = byId.get(id); - return ( -
    • - -
      -

      - {h?.name ?? "Hackathon"} -

      - {h && ( -

      - {formatDates(h.startDate, h.endDate)} - {h.location ? ` · ${h.location}` : ""} -

      - )} -
      -
    • - ); - })} -
    -
    - )} - - {waiting.length > 0 && ( -
    -

    Waiting for approval

    -
      - {waiting.map((id) => ( -
    • -

      - {byId.get(id)?.name ?? "Hackathon"} -

      - Applied -
    • - ))} -
    -

    - You will get an email when an organiser approves you. -

    -
    - )} - -
    -

    Taking judges

    - {canApply.length === 0 ? ( -

    - No edition is taking judge applications right now. -

    - ) : ( -
      - {canApply.map((h) => ( -
    • -
      -

      {h.name}

      -

      - {formatDates(h.startDate, h.endDate)} -

      -
      - - Apply to judge - -
    • - ))} -
    - )} -
    -
    + [ + ["judge.isJudge", undefined, c.judge.isJudge()], + ["judge.myApplications", undefined, c.judge.myApplications()], + ["hackathon.list", { limit: 50 }, c.hackathon.list({ limit: 50 })], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/settings/SettingsPage.tsx b/sites/mainweb/app/(portal)/settings/SettingsPage.tsx new file mode 100644 index 00000000..5041e758 --- /dev/null +++ b/sites/mainweb/app/(portal)/settings/SettingsPage.tsx @@ -0,0 +1,518 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { loginHref } from "@/lib/safe-callback"; +import { useSession, signOut } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { Check, LogOut } from "lucide-react"; +import { MembershipTab } from "@/components/portal/MembershipTab"; +import { ResumeSection } from "@/components/portal/ResumeSection"; +import Image from "next/image"; +import { trpc } from "@/lib/trpc"; +import { trpcErrorMessage } from "@/lib/trpc-error"; +import { useIsClient } from "@/lib/use-is-client"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import { + page, + pageTitle, + pageDek, + sectionTitle, + sectionRule, + meta, + body, + fieldLabel, + fieldHint, + input, + btnPrimary, + btnSecondary, + tabList, + tab as tabClass, + status as statusClass, +} from "@/components/portal/ui"; + +const readOnlyInput = + "w-full rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--bg-secondary)] px-3.5 py-2.5 text-[15px] text-[var(--text-muted)] cursor-not-allowed focus:outline-none"; + +export default function SettingsPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + const { theme, setTheme } = useTheme(); + const mounted = useIsClient(); + const [activeTab, setActiveTab] = useState< + "profile" | "membership" | "appearance" | "account" + >("profile"); + const [isSaving, setIsSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [isUploadingImage, setIsUploadingImage] = useState(false); + + const { data: userData } = trpc.user.me.useQuery(undefined, { enabled: !!session }); + const updateProfile = trpc.user.updateProfile.useMutation(); + const updateProfileImage = trpc.user.updateProfileImage.useMutation(); + const utils = trpc.useUtils(); + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsUploadingImage(true); + + try { + // 1. Read file as Data URL + const reader = new FileReader(); + reader.onloadend = async () => { + try { + const originalBase64 = reader.result as string; + + // 2. Client-side compression via Canvas to avoid overkilling the database + const img = new window.Image(); + img.src = originalBase64; + + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + + // Max dimensions for an avatar + const MAX_SIZE = 400; + let width = img.width; + let height = img.height; + + if (width > height && width > MAX_SIZE) { + height *= MAX_SIZE / width; + width = MAX_SIZE; + } else if (height > MAX_SIZE) { + width *= MAX_SIZE / height; + height = MAX_SIZE; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Could not get canvas context"); + + ctx.drawImage(img, 0, 0, width, height); + + // Compress to WebP at 80% quality + const compressedBase64 = canvas.toDataURL("image/webp", 0.8); + + // 3. Upload the tiny payload + await updateProfileImage.mutateAsync({ base64Image: compressedBase64 }); + await utils.user.me.invalidate(); + } catch (err) { + console.error("Failed to process and upload image:", err); + // Optional: show a toast notification here + } finally { + setIsUploadingImage(false); + } + }; + + reader.onerror = () => { + console.error("FileReader failed"); + setIsUploadingImage(false); + }; + + reader.readAsDataURL(file); + } catch (e) { + console.error(e); + setIsUploadingImage(false); + } + }; + + const [form, setForm] = useState({ + name: "", + bio: "", + website: "", + location: "", + gtEmail: "", + }); + + useEffect(() => { + if (status === "unauthenticated") router.push(loginHref()); + }, [status, router]); + + // Refill the form whenever new user data arrives. Done during render rather + // than in an effect, so the stale form never paints. + const [filledFrom, setFilledFrom] = useState(undefined); + if (userData && userData !== filledFrom) { + setFilledFrom(userData); + setForm({ + name: userData.name || "", + bio: userData.bio || "", + website: userData.website || "", + location: userData.location || "", + gtEmail: userData.gtEmail || "", + }); + } + + const handleSave = async () => { + setIsSaving(true); + try { + // Sent as-is, not `|| undefined`: an emptied field means "clear it", and + // collapsing it to undefined made the server skip it while the UI still + // reported a save. + await updateProfile.mutateAsync({ + name: form.name || undefined, + bio: form.bio, + website: form.website, + location: form.location, + gtEmail: form.gtEmail.trim(), + }); + await utils.user.me.invalidate(); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch (e) { + console.error(e); + } finally { + setIsSaving(false); + } + }; + + if (status === "loading") return ; + if (!session) return null; + + const tabs = [ + { id: "profile", label: "Profile", desc: "Name, photo, bio and resume" }, + // The club member profile: school, major, skills, interests, socials. The + // columns and the procedures existed with no screen behind them. + { id: "membership", label: "Membership", desc: "Status, school and links" }, + { id: "appearance", label: "Appearance", desc: "Paper or night" }, + { id: "account", label: "Account", desc: "Google sign-in and sign out" }, + ] as const; + + // Each swatch sets its own theme class so it is drawn with that edition's + // tokens, whichever edition the page is in. + const themes = [ + { id: "light", name: "Paper", desc: "Bone ground, near-black ink", scope: "light" }, + { id: "dark", name: "Night", desc: "Deep ground, soft ink", scope: "dark" }, + ] as const; + + return ( +
    +

    Settings

    +

    + Your profile, club membership, theme and account. +

    + +
    + {/* Tabs: horizontal on small screens, a vertical list from lg up */} +
    + {tabs.map((t) => ( + + ))} +
    + + + {/* Content */} +
    + {/* Profile Tab */} + {activeTab === "profile" && ( +
    +

    Profile

    + + {/* Avatar */} +
    +