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..3355b546 --- /dev/null +++ b/sites/mainweb/app/(portal)/club/ClubPage.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { venueFormat } from "@/lib/venue-time"; +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) => + venueFormat(date, { + 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 · " + : ""} + {venueFormat(checkIn.checkedInAt)} + {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 + ? venueFormat(memberStatus.expiresAt) + : "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..70fa809a --- /dev/null +++ b/sites/mainweb/app/(portal)/dashboard/DashboardPage.tsx @@ -0,0 +1,593 @@ +"use client"; + +import { venueDayNumber, venueFormat } from "@/lib/venue-time"; +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 today = venueDayNumber(new Date()); + return query.state.data?.some( + (r) => + r.registrationStatus === "approved" && + venueDayNumber(r.hackathon.startDate) <= today && + today <= venueDayNumber(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 = venueFormat(now, { + weekday: "long", + month: "long", + day: "numeric", + }); + + const daysLeft = memberStatus?.daysRemaining; + const endsSoon = typeof daysLeft === "number" && daysLeft <= 30; + const expiresOn = memberStatus?.expiresAt + ? venueFormat(memberStatus.expiresAt, { + 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)/hackathons/[id]/HackathonDetailPage.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/HackathonDetailPage.tsx new file mode 100644 index 00000000..63e8af4e --- /dev/null +++ b/sites/mainweb/app/(portal)/hackathons/[id]/HackathonDetailPage.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { venueDayNumber, venueFormat, venueParts } from "@/lib/venue-time"; +import React, { + useState, + useEffect, + useOptimistic, + useRef, + startTransition, +} from "react"; +import { loginHref } from "@/lib/safe-callback"; +import { useSession } from "next-auth/react"; +import { trpc } from "@/lib/trpc"; +import { useRouter, useParams, useSearchParams } from "next/navigation"; +import { LoadingScreen } from "@/components/portal/LoadingScreen"; +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { + meta, + page, + pageDek, + pageTitle, + status, + tab as tabClass, + tabList, +} from "@/components/portal/ui"; +import { hackathonStatus } from "@/lib/hackathon-status"; + +// Extracted Tab Components +import { InfoTab } from "@/components/hackathon/InfoTab"; +import { StatusBadge } from "@/components/hackathon/StatusBadge"; +import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; +import { HackerAnnouncements } from "@/components/hackathon/HackerAnnouncements"; +import { NextStepStrip } from "@/components/hackathon/NextStepStrip"; +import { decodeHackathonParam } from "@/lib/hackathon-slug"; +import { + hackathonNextStep, + nextStepQueries, + projectSubmitted, +} from "@/lib/hackathon-next-step"; + +// Only the open tab is needed to draw, so the others stay out of the first +// load. They are fetched once the browser is idle, so a click finds them ready. +const loadScheduleTab = () => import("@/components/hackathon/ScheduleTab").then((m) => m.ScheduleTab); +const ScheduleTab = dynamic(loadScheduleTab); +const loadProjectsTab = () => import("@/components/hackathon/ProjectsTab").then((m) => m.ProjectsTab); +const ProjectsTab = dynamic(loadProjectsTab); +const loadResultsTab = () => import("@/components/hackathon/ResultsTab").then((m) => m.ResultsTab); +const ResultsTab = dynamic(loadResultsTab); +const loadTeamsTab = () => import("@/components/hackathon/TeamsTab").then((m) => m.TeamsTab); +const TeamsTab = dynamic(loadTeamsTab); +const loadHackerJudgingTab = () => import("@/components/hackathon/judging/HackerJudgingTab").then((m) => m.HackerJudgingTab); +const HackerJudgingTab = dynamic(loadHackerJudgingTab); +const loadPointsTab = () => import("@/components/hackathon/points/PointsTab").then((m) => m.PointsTab); +const PointsTab = dynamic(loadPointsTab); +const preloadTabs = () => { + for (const load of [loadScheduleTab, loadProjectsTab, loadResultsTab, loadTeamsTab, loadHackerJudgingTab, loadPointsTab]) void load(); +}; + +function formatDate(d: Date | string) { + return venueFormat(d, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatDateRange(start: Date | string, end: Date | string) { + const s = venueParts(start); + const e = venueParts(end); + const short = { month: "short", day: "numeric" } as const; + if (s.month === e.month && s.year === e.year) { + return `${venueFormat(start, short)} - ${e.day}, ${e.year}`; + } + if (s.year === e.year) { + return `${venueFormat(start, short)} - ${venueFormat(end, short)}, ${e.year}`; + } + return `${formatDate(start)} - ${formatDate(end)}`; +} + +type TabType = + | "INFO" + | "SCHEDULE" + | "PROJECTS" + | "TEAMS" + | "JUDGING" + | "POINTS" + | "RESULTS"; + +const TABS: { id: TabType; label: string }[] = [ + { id: "INFO", label: "Overview" }, + { id: "SCHEDULE", label: "Schedule & pass" }, + { id: "PROJECTS", label: "Projects" }, + { id: "TEAMS", label: "Teams" }, + { id: "JUDGING", label: "Judging" }, + { id: "POINTS", label: "Points" }, + { id: "RESULTS", label: "Results" }, +]; + +// Nothing to judge, score or announce before the event runs, so these stay +// out of the strip until it does. +const EVENT_TABS = new Set(["JUDGING", "POINTS", "RESULTS"]); +const EVENT_STATUSES = new Set(["in_progress", "completed"]); + +// The strip's right edge fades out so on a phone it hints at the tabs past it. +const tabFade = + "[mask-image:linear-gradient(to_right,#000_calc(100%_-_1.5rem),transparent)]"; + +// Day granularity, on the venue's calendar: the last day counts all day. +const startOfDay = venueDayNumber; + +export default function HackathonDetailPage() { + const { data: session, status: authStatus } = useSession(); + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const rawId = params.id; + const hackathonId = decodeHackathonParam( + Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), + ); + + // The URL is the only source of the tab, so a same-page ?tab= link works. + // The optimistic copy shows a clicked tab at once instead of after the + // navigation lands, then hands back to whatever the URL says. + const tabParam = searchParams.get("tab"); + const urlTab = TABS.find((t) => t.id === tabParam)?.id ?? "INFO"; + const [shownTab, setShownTab] = useOptimistic(urlTab); + + useEffect(() => { + const id = window.setTimeout(preloadTabs, 1000); + return () => window.clearTimeout(id); + }, []); + + // Keep the tab in the URL so refreshing, sharing and Back all work. Next's + // history.replaceState keeps useSearchParams in step without asking the + // server to render the page again, which router.replace did on every click. + const selectTab = (next: TabType) => { + startTransition(() => { + setShownTab(next); + const qs = new URLSearchParams(searchParams.toString()); + qs.set("tab", next); + window.history.replaceState(null, "", `?${qs.toString()}`); + }); + }; + + const { + data: hackathon, + isLoading, + error, + } = trpc.hackathon.getById.useQuery({ + id: hackathonId, + }); + const { data: myRegs } = trpc.hackathon.myRegistrations.useQuery(undefined, { + enabled: !!session, + // An accepted hacker is waiting on the door scan. While the event runs, + // re-read so the page flips to checked in without a reload. + refetchInterval: (query) => { + const today = startOfDay(new Date()); + return query.state.data?.some( + (r) => + r.hackathonId === hackathon?.id && + r.registrationStatus === "approved" && + startOfDay(r.hackathon.startDate) <= today && + today <= startOfDay(r.hackathon.endDate), + ) + ? 30_000 + : false; + }, + }); + + const myReg = myRegs?.find((r) => r.hackathonId === hackathon?.id); + + // Only a flip seen while the page is open counts; the status it first + // loaded with never announces itself. + const regStatus = myReg?.registrationStatus; + const [seenStatus, setSeenStatus] = useState(regStatus); + const [justCheckedIn, setJustCheckedIn] = useState(false); + if (regStatus !== seenStatus) { + setSeenStatus(regStatus); + if (regStatus === "checked_in" && seenStatus) setJustCheckedIn(true); + } + useEffect(() => { + if (!justCheckedIn) return; + const timer = setTimeout(() => setJustCheckedIn(false), 10_000); + return () => clearTimeout(timer); + }, [justCheckedIn]); + + // The next-step strip reads up to four more queries, each held back until + // the step can use it, so only a registered hacker at the right point pays. + // oxlint-disable-next-line react/purity + const now = new Date(); + const needs = + hackathon && regStatus + ? nextStepQueries({ now, hackathon, registrationStatus: regStatus }) + : { submission: false, judging: false, results: false }; + const stepId = hackathon?.id ?? ""; + const submissionWindow = trpc.team.submissionWindow.useQuery( + { hackathonId: stepId }, + { enabled: needs.submission }, + ); + const mySubmission = trpc.team.mySubmission.useQuery( + { hackathonId: stepId }, + { enabled: needs.submission }, + ); + const itinerary = trpc.judging.hacker.itinerary.useQuery( + { hackathonId: stepId }, + { enabled: needs.judging }, + ); + const results = trpc.hackathon.getResults.useQuery( + { hackathonId: stepId }, + { enabled: 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; + + useEffect(() => { + if (authStatus === "unauthenticated") router.push(loginHref()); + }, [authStatus, router]); + + // The strip scrolls sideways on a phone, so a ?tab= link to a later tab + // would land with it and its accent rule past the right edge. Scroll the + // strip itself: scrollIntoView would move the page too. `tabsMounted` is the + // render the strip first exists in; the tab alone does not change then. + const tabsRef = useRef(null); + const tabsMounted = + authStatus !== "loading" && !isLoading && !!session && !error && !!hackathon; + useEffect(() => { + const list = tabsRef.current; + const el = list?.querySelector(`#tab-${shownTab}`); + if (!list || !el) return; + const clipped = + el.offsetLeft < list.scrollLeft || + el.offsetLeft + el.offsetWidth > list.scrollLeft + list.clientWidth - 24; + if (clipped) list.scrollLeft = Math.max(0, el.offsetLeft - 24); + }, [shownTab, tabsMounted]); + + if (authStatus === "loading" || isLoading) + 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 + // forever with nothing to click. + if (error || !hackathon) return ; + + const isRegistered = !!myReg; + const nextStep = + myReg && !stepLoading + ? hackathonNextStep({ + now, + hackathon, + registrationStatus: myReg.registrationStatus, + hasTeam: !!myReg.teamId, + submissionWindow: submissionWindow.data, + submitted: projectSubmitted(mySubmission.data), + itinerary: itinerary.data, + resultsPublished: (results.data?.length ?? 0) > 0, + }) + : null; + // The status is moved by hand, so the start date counts too: an edition + // still marked closed on the weekend itself must not hide Judging. + const eventLive = + EVENT_STATUSES.has(hackathon.status) || + (hackathon.status !== "cancelled" && + // oxlint-disable-next-line react/purity + startOfDay(hackathon.startDate) <= startOfDay(new Date())); + const tabs = eventLive ? TABS : TABS.filter((t) => !EVENT_TABS.has(t.id)); + // A ?tab= for a tab that is not shown yet falls back to the overview. + const tab = tabs.some((t) => t.id === shownTab) ? shownTab : "INFO"; + const conf = hackathonStatus(hackathon.status); + + return ( +
    + + ← All hackathons + + +
    +
    + {conf.label} + {myReg && } +
    +

    {hackathon.name}

    + {hackathon.theme &&

    {hackathon.theme}

    } +

    + {[ + formatDateRange(hackathon.startDate, hackathon.endDate), + hackathon.location, + ] + .filter(Boolean) + .join(" · ")} +

    + {hackathon.maxParticipants ? ( +

    + {`${hackathon.currentParticipants} of ${hackathon.maxParticipants} spots taken`} +

    + ) : null} + {nextStep && } +
    + + {/* Mounted empty so screen readers announce the line when it lands. */} +
    + {justCheckedIn && ( +

    + You're checked in. +

    + )} +
    + + {/* What organisers announced to this hacker, emailed or in app only. + The uuid, not the route param: that may be a slug. */} + {regStatus && } + + {/* Tabs. The end padding lets the last tab scroll clear of the fade. */} +
    + {tabs.map((t) => ( + + ))} +
    + + {/* Content */} +
    + {tab === "INFO" ? ( + + ) : tab === "SCHEDULE" ? ( + + ) : tab === "JUDGING" ? ( + + ) : tab === "POINTS" ? ( + + ) : tab === "RESULTS" ? ( + + ) : tab === "PROJECTS" ? ( + + ) : ( + + )} +
    +
    + ); +} diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx index 3e906bfb..ea8b93e7 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/page.tsx @@ -1,362 +1,20 @@ -"use client"; - -import React, { - useState, - useEffect, - useOptimistic, - useRef, - startTransition, -} from "react"; -import { loginHref } from "@/lib/safe-callback"; -import { useSession } from "next-auth/react"; -import { trpc } from "@/lib/trpc"; -import { useRouter, useParams, useSearchParams } from "next/navigation"; -import { LoadingScreen } from "@/components/portal/LoadingScreen"; -import Link from "next/link"; -import { - meta, - page, - pageDek, - pageTitle, - status, - tab as tabClass, - tabList, -} from "@/components/portal/ui"; -import { hackathonStatus } from "@/lib/hackathon-status"; - -// Extracted Tab Components -import { InfoTab } from "@/components/hackathon/InfoTab"; -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 { HackerJudgingTab } from "@/components/hackathon/judging/HackerJudgingTab"; -import { PointsTab } from "@/components/hackathon/points/PointsTab"; -import { StatusBadge } from "@/components/hackathon/StatusBadge"; -import { HackathonUnavailable } from "@/components/hackathon/HackathonUnavailable"; -import { HackerAnnouncements } from "@/components/hackathon/HackerAnnouncements"; -import { NextStepStrip } from "@/components/hackathon/NextStepStrip"; +import { PrefetchedPage } from "@/components/portal/PrefetchedPage"; import { decodeHackathonParam } from "@/lib/hackathon-slug"; -import { - hackathonNextStep, - nextStepQueries, - projectSubmitted, -} from "@/lib/hackathon-next-step"; - -function formatDate(d: Date | string) { - return new Date(d).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} - -function formatDateRange(start: Date | string, end: Date | string) { - const s = new Date(start); - const e = new Date(end); - if (s.getMonth() === e.getMonth() && s.getFullYear() === e.getFullYear()) { - return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${e.getDate()}, ${e.getFullYear()}`; - } - if (s.getFullYear() === e.getFullYear()) { - return `${s.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${e.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${e.getFullYear()}`; - } - return `${formatDate(s)} - ${formatDate(e)}`; -} - -type TabType = - | "INFO" - | "SCHEDULE" - | "PROJECTS" - | "TEAMS" - | "JUDGING" - | "POINTS" - | "RESULTS"; - -const TABS: { id: TabType; label: string }[] = [ - { id: "INFO", label: "Overview" }, - { id: "SCHEDULE", label: "Schedule & pass" }, - { id: "PROJECTS", label: "Projects" }, - { id: "TEAMS", label: "Teams" }, - { id: "JUDGING", label: "Judging" }, - { id: "POINTS", label: "Points" }, - { id: "RESULTS", label: "Results" }, -]; - -// Nothing to judge, score or announce before the event runs, so these stay -// out of the strip until it does. -const EVENT_TABS = new Set(["JUDGING", "POINTS", "RESULTS"]); -const EVENT_STATUSES = new Set(["in_progress", "completed"]); - -// The strip's right edge fades out so on a phone it hints at the tabs past it. -const tabFade = - "[mask-image:linear-gradient(to_right,#000_calc(100%_-_1.5rem),transparent)]"; - -// Day granularity, on the viewer's clock: the last day counts all day. -const startOfDay = (d: Date | string) => new Date(d).setHours(0, 0, 0, 0); - -export default function HackathonDetailPage() { - const { data: session, status: authStatus } = useSession(); - const router = useRouter(); - const params = useParams(); - const searchParams = useSearchParams(); - const rawId = params.id; - const hackathonId = decodeHackathonParam( - Array.isArray(rawId) ? (rawId[0] ?? "") : ((rawId as string | undefined) ?? ""), - ); - - // The URL is the only source of the tab, so a same-page ?tab= link works. - // The optimistic copy shows a clicked tab at once instead of after the - // navigation lands, then hands back to whatever the URL says. - const tabParam = searchParams.get("tab"); - const urlTab = TABS.find((t) => t.id === tabParam)?.id ?? "INFO"; - const [shownTab, setShownTab] = useOptimistic(urlTab); - - // Keep the tab in the URL so refreshing, sharing and Back all work. - const selectTab = (next: TabType) => { - startTransition(() => { - setShownTab(next); - const qs = new URLSearchParams(searchParams.toString()); - qs.set("tab", next); - router.replace(`?${qs.toString()}`, { scroll: false }); - }); - }; - - const { - data: hackathon, - isLoading, - error, - } = trpc.hackathon.getById.useQuery({ - id: hackathonId, - }); - const { data: myRegs } = trpc.hackathon.myRegistrations.useQuery(undefined, { - enabled: !!session, - // An accepted hacker is waiting on the door scan. While the event runs, - // re-read so the page flips to checked in without a reload. - refetchInterval: (query) => { - const today = startOfDay(new Date()); - return query.state.data?.some( - (r) => - r.hackathonId === hackathon?.id && - r.registrationStatus === "approved" && - startOfDay(r.hackathon.startDate) <= today && - today <= startOfDay(r.hackathon.endDate), - ) - ? 30_000 - : false; - }, - }); - - const myReg = myRegs?.find((r) => r.hackathonId === hackathon?.id); - - // Only a flip seen while the page is open counts; the status it first - // loaded with never announces itself. - const regStatus = myReg?.registrationStatus; - const [seenStatus, setSeenStatus] = useState(regStatus); - const [justCheckedIn, setJustCheckedIn] = useState(false); - if (regStatus !== seenStatus) { - setSeenStatus(regStatus); - if (regStatus === "checked_in" && seenStatus) setJustCheckedIn(true); - } - useEffect(() => { - if (!justCheckedIn) return; - const timer = setTimeout(() => setJustCheckedIn(false), 10_000); - return () => clearTimeout(timer); - }, [justCheckedIn]); - - // The next-step strip reads up to four more queries, each held back until - // the step can use it, so only a registered hacker at the right point pays. - // oxlint-disable-next-line react/purity - const now = new Date(); - const needs = - hackathon && regStatus - ? nextStepQueries({ now, hackathon, registrationStatus: regStatus }) - : { submission: false, judging: false, results: false }; - const stepId = hackathon?.id ?? ""; - const submissionWindow = trpc.team.submissionWindow.useQuery( - { hackathonId: stepId }, - { enabled: needs.submission }, - ); - const mySubmission = trpc.team.mySubmission.useQuery( - { hackathonId: stepId }, - { enabled: needs.submission }, - ); - const itinerary = trpc.judging.hacker.itinerary.useQuery( - { hackathonId: stepId }, - { enabled: needs.judging }, - ); - const results = trpc.hackathon.getResults.useQuery( - { hackathonId: stepId }, - { enabled: 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; - - useEffect(() => { - if (authStatus === "unauthenticated") router.push(loginHref()); - }, [authStatus, router]); - - // The strip scrolls sideways on a phone, so a ?tab= link to a later tab - // would land with it and its accent rule past the right edge. Scroll the - // strip itself: scrollIntoView would move the page too. `tabsMounted` is the - // render the strip first exists in; the tab alone does not change then. - const tabsRef = useRef(null); - const tabsMounted = - authStatus !== "loading" && !isLoading && !!session && !error && !!hackathon; - useEffect(() => { - const list = tabsRef.current; - const el = list?.querySelector(`#tab-${shownTab}`); - if (!list || !el) return; - const clipped = - el.offsetLeft < list.scrollLeft || - el.offsetLeft + el.offsetWidth > list.scrollLeft + list.clientWidth - 24; - if (clipped) list.scrollLeft = Math.max(0, el.offsetLeft - 24); - }, [shownTab, tabsMounted]); - - if (authStatus === "loading" || isLoading) - 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 - // forever with nothing to click. - if (error || !hackathon) return ; - - const isRegistered = !!myReg; - const nextStep = - myReg && !stepLoading - ? hackathonNextStep({ - now, - hackathon, - registrationStatus: myReg.registrationStatus, - hasTeam: !!myReg.teamId, - submissionWindow: submissionWindow.data, - submitted: projectSubmitted(mySubmission.data), - itinerary: itinerary.data, - resultsPublished: (results.data?.length ?? 0) > 0, - }) - : null; - // The status is moved by hand, so the start date counts too: an edition - // still marked closed on the weekend itself must not hide Judging. - const eventLive = - EVENT_STATUSES.has(hackathon.status) || - (hackathon.status !== "cancelled" && - // oxlint-disable-next-line react/purity - startOfDay(hackathon.startDate) <= startOfDay(new Date())); - const tabs = eventLive ? TABS : TABS.filter((t) => !EVENT_TABS.has(t.id)); - // A ?tab= for a tab that is not shown yet falls back to the overview. - const tab = tabs.some((t) => t.id === shownTab) ? shownTab : "INFO"; - const conf = hackathonStatus(hackathon.status); - +import HackathonDetailPage from "./HackathonDetailPage"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const id = decodeHackathonParam((await params).id); return ( -
    - - ← All hackathons - - -
    -
    - {conf.label} - {myReg && } -
    -

    {hackathon.name}

    - {hackathon.theme &&

    {hackathon.theme}

    } -

    - {[ - formatDateRange(hackathon.startDate, hackathon.endDate), - hackathon.location, - ] - .filter(Boolean) - .join(" · ")} -

    - {hackathon.maxParticipants ? ( -

    - {`${hackathon.currentParticipants} of ${hackathon.maxParticipants} spots taken`} -

    - ) : null} - {nextStep && } -
    - - {/* Mounted empty so screen readers announce the line when it lands. */} -
    - {justCheckedIn && ( -

    - You're checked in. -

    - )} -
    - - {/* What organisers announced to this hacker, emailed or in app only. - The uuid, not the route param: that may be a slug. */} - {regStatus && } - - {/* Tabs. The end padding lets the last tab scroll clear of the fade. */} -
    - {tabs.map((t) => ( - - ))} -
    - - {/* Content */} -
    - {tab === "INFO" ? ( - - ) : tab === "SCHEDULE" ? ( - - ) : tab === "JUDGING" ? ( - - ) : tab === "POINTS" ? ( - - ) : tab === "RESULTS" ? ( - - ) : tab === "PROJECTS" ? ( - - ) : ( - - )} -
    -
    + [ + ["hackathon.getById", { id }, c.hackathon.getById({ id })], + ]} + > + + ); } diff --git a/sites/mainweb/app/(portal)/judge/JudgePage.tsx b/sites/mainweb/app/(portal)/judge/JudgePage.tsx new file mode 100644 index 00000000..055e1bab --- /dev/null +++ b/sites/mainweb/app/(portal)/judge/JudgePage.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { venueFormat } from "@/lib/venue-time"; +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" }; + return `${venueFormat(start, opts)} – ${venueFormat(end, { ...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)/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/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 */} +
    +