From 3f668a101d28c0d0ddb9504eb646dda770820683 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 1 Oct 2026 21:49:47 -0400 Subject: [PATCH] feat(judging): tap an NFC tag on the table to start judging it Table cards keep their QR code; an NFC tag is an optional second way in. The tag holds the judge page URL with ?table=. Tapping it opens the page (iPhone background read or Android) and, once judge access and an open round are confirmed, calls startByQrCode with that code: the same start, clock and wrong-table check as scanning the card. The param is removed after use so a reload does not replay it, and sign-in keeps the full URL, so a signed-out judge comes back to the table. Each table card gets Copy link (for writing tags from an NFC app) and, on Chrome for Android, Write NFC tag via Web NFC. Neither prints. The in-page scanner also accepts the link form of the code. --- .../(portal)/hackathons/[id]/judge/page.tsx | 42 ++++++-- .../admin/hackathons/TableCards.tsx | 96 ++++++++++++++++++- 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index 3e0f28dc..60f16359 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { loginHref } from "@/lib/safe-callback"; import { useSession } from "next-auth/react"; import { useParams, useRouter } from "next/navigation"; @@ -257,6 +257,26 @@ export default function JudgeHackathonPage() { onError: recoverOrReport, }); + /** + * Tap-to-start. The NFC tag on a table holds this page's URL with + * ?table=, so tapping it opens the page and starts that table exactly + * as scanning the printed card would. Waits for judge access and an open + * round; the param is dropped once used so a reload cannot replay it + * (startByQrCode is idempotent anyway). + */ + const tappedTable = useRef(false); + useEffect(() => { + if (tappedTable.current) return; + if (!hackathonId || judgeCheck.data?.isJudge !== true || !judgingOpen) return; + const url = new URL(window.location.href); + const code = url.searchParams.get("table"); + if (!code) return; + tappedTable.current = true; + url.searchParams.delete("table"); + window.history.replaceState(null, "", url.pathname + url.search + url.hash); + startByQr.mutate({ qrCode: code.trim() }); + }, [hackathonId, judgeCheck.data?.isJudge, judgingOpen, startByQr]); + useEffect(() => { if (status === "unauthenticated") router.push(loginHref()); }, [status, router]); @@ -478,9 +498,10 @@ export default function JudgeHackathonPage() { Scan the team's code to begin

- Walk to table {project.tableNumber ?? "?"} and scan the card - on their desk. Your judging time starts from the scan, not - from when this table was assigned to you. + Walk to table {project.tableNumber ?? "?"} and tap your phone + on the NFC tag, or scan the card on their desk. Your judging + time starts from then, not from when this table was assigned + to you.

+ )} + + {(writes[card.id] === "written" || writes[card.id] === "copied") && ( + + + {writes[card.id] === "written" ? "Tag written" : "Copied"} + + )} + {typeof writes[card.id] === "object" && ( +

+ {(writes[card.id] as { error: string }).error} +

+ )} + ))}