From f1b3a862f85401482e308be14603a7dfb62c6cff Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Sat, 3 Oct 2026 16:02:29 +0200 Subject: [PATCH] Phase 8: share panel, polish, accessibility and deploy config - Share links section: scoreboard link, every judged game's judge links and open link with QR codes, and 'Copy all judge links' as plain text for WhatsApp. Warns when links point at localhost. - Error and not-found pages with plain-language copy; a real start page. - Accessibility: darker 'alert strong' red for text and white-on-red (Alert was 4.0:1 on white), no text below 70% ink, 44px block buttons and link targets, scoreboard status stays readable when idle. - vercel.json pins functions to Dublin next to the West EU database; README documents deployment and pre-game-night checks. Co-Authored-By: Claude Opus 5.5 --- README.md | 35 ++++++- docs/BUILD_PLAN.md | 2 +- docs/UI.md | 5 +- src/app/admin/events/[eventId]/page.tsx | 8 +- src/app/error.tsx | 28 ++++++ src/app/globals.css | 3 + src/app/login/login-form.tsx | 2 +- src/app/not-found.tsx | 17 ++++ src/app/page.tsx | 38 +++---- src/components/admin/bonus-panel.tsx | 2 +- src/components/admin/control-room.tsx | 5 +- src/components/admin/error-text.tsx | 2 +- src/components/admin/judged-panel.tsx | 2 +- src/components/admin/judges-editor.tsx | 2 +- src/components/admin/scoreboard-panel.tsx | 2 +- src/components/admin/scoring-grid.tsx | 4 +- src/components/admin/share-panel.test.ts | 20 ++++ src/components/admin/share-panel.tsx | 116 ++++++++++++++++++++++ src/components/judge/judge-view.tsx | 2 +- src/components/judge/open-link-form.tsx | 2 +- src/components/scoreboard/scoreboard.tsx | 2 +- src/components/ui/button.tsx | 2 +- src/components/ui/field.tsx | 2 +- src/components/ui/score-inputs.tsx | 2 +- vercel.json | 4 + 25 files changed, 264 insertions(+), 45 deletions(-) create mode 100644 src/app/error.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/components/admin/share-panel.test.ts create mode 100644 src/components/admin/share-panel.tsx create mode 100644 vercel.json diff --git a/README.md b/README.md index 9b96876..4184028 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,36 @@ an admin control room, a projector scoreboard, and per-game judge links. - Open `docs/KICKOFF_PROMPT.md`, paste its contents into Claude Code. - Let it go phase by phase. After each phase: run it, click around, commit, then say "next phase". -## Tips +## Commands -- Keep `docs/` as the source of truth. If you change your mind on a feature, edit the doc first, then tell Claude Code "I updated PRD.md section X, implement it". -- Test the scoreboard on the actual projector PC + one judge on a phone before game night. Wi-Fi at church is the #1 thing that'll bite you. +| Command | What it does | +|---|---| +| `npm run dev` | Run locally at http://localhost:3000 | +| `npm run test` / `npm run test:coverage` | Unit tests (scoring engine must stay at 100%) | +| `npm run lint` / `npm run build` | Lint / production build | +| `npm run check:rls` | Proves the anon key cannot read or write any table | +| `npm run gen:types` | Regenerate `src/lib/database.types.ts` after a migration | + +## Deploying to Vercel + +1. Push this repo to GitHub, then in Vercel choose **Add New → Project** and import it. +2. Add these environment variables in Vercel (Production): `NEXT_PUBLIC_SUPABASE_URL`, + `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `ADMIN_EMAILS`, + `NEXT_PUBLIC_APP_NAME`, and `NEXT_PUBLIC_SITE_URL` set to the site's address + (e.g. `https://your-project.vercel.app`, no trailing slash). The build fails with a clear + message if any is missing. +3. Deploy. `vercel.json` runs the server functions in Dublin (`dub1`), next to a West EU + Supabase project; change it if your database is elsewhere. +4. In Supabase → Authentication → URL Configuration, set **Site URL** to the Vercel address and + add `https://your-project.vercel.app/auth/confirm` to **Redirect URLs** (keep the localhost + one for development). +5. If you change `NEXT_PUBLIC_SITE_URL` later, redeploy: share links and QR codes are built from it. + +## Before game night + +- Free Supabase projects pause after about a week without use. Open the Supabase dashboard a + day before and click **Restore** if it is paused. +- Sign in as host the day before; the magic-link email is limited to a few per hour. +- Test the scoreboard on the actual projector PC and one judge link on a phone, on the venue's + Wi-Fi. The scoreboard also polls every 10 seconds if live updates are blocked. +- Keep `docs/` as the source of truth: change the doc first, then the code. diff --git a/docs/BUILD_PLAN.md b/docs/BUILD_PLAN.md index 4fb6681..f2c0937 100644 --- a/docs/BUILD_PLAN.md +++ b/docs/BUILD_PLAN.md @@ -5,7 +5,7 @@ human a short "how to test this manually" checklist. Don't start the next phase ## Phase 0 — Scaffold - Next.js + TS strict + Tailwind + ESLint + Vitest. `src/` layout per ARCHITECTURE.md. -- Fonts: Big Shoulders Display + Atkinson Hyperlegible via `next/font/google`. Tokens from UI.md as CSS variables + Tailwind theme. +- Fonts: Big Shoulders (Google's new name for Big Shoulders Display) + Atkinson Hyperlegible via `next/font/google`. Tokens from UI.md as CSS variables + Tailwind theme. - Supabase clients (`browser`, `server`, `admin` with `server-only`). - `.env.local` loading; fail fast with a clear error if vars are missing. **Done when:** `npm run dev` shows a placeholder home; `npm run test` runs a dummy test. diff --git a/docs/UI.md b/docs/UI.md index d9688d1..1917d7a 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -12,11 +12,12 @@ Spend the boldness in ONE place: the scoreboard's rank-change moment. Admin + ju | Chalk | `#F5F7FA` | Admin/judge background | | Ink | `#14213D` | Primary text on light | | Signal | `#2EC4B6` | Saved/connected state | -| Alert | `#E63946` | Errors, penalties, hidden state | +| Alert | `#E63946` | Penalty/hidden accents, borders, status dots | +| Alert strong | `#C1121F` | Error text on light backgrounds, and red fills under white text (Alert itself is only 4.0:1 on white) | Team colours are user-chosen; pick text colour per team by contrast (white or Ink). Type: -- **Big Shoulders Display** (Google Fonts) — scoreboard numbers, team names, game titles. Tabular numbers. +- **Big Shoulders** (Google Fonts; formerly "Big Shoulders Display") — scoreboard numbers, team names, game titles. Tabular numbers. - **Atkinson Hyperlegible** — all body/UI text (readable on phones + projector). Sentence case everywhere. No all-caps labels. diff --git a/src/app/admin/events/[eventId]/page.tsx b/src/app/admin/events/[eventId]/page.tsx index f0df0c0..b33e03d 100644 --- a/src/app/admin/events/[eventId]/page.tsx +++ b/src/app/admin/events/[eventId]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { EventDetailsForm } from "@/components/admin/event-details-form"; import { GamesEditor } from "@/components/admin/games-editor"; +import { SharePanel } from "@/components/admin/share-panel"; import { TeamsEditor } from "@/components/admin/teams-editor"; import { buttonClass } from "@/components/ui/button"; import { requireAdmin } from "@/lib/auth"; @@ -21,7 +22,7 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev return (
- + ← All game nights
@@ -59,6 +60,11 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev

Games

+ +
+

Share links

+ +
); } diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..435e0a7 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,28 @@ +"use client"; // Error boundaries must be Client Components + +import { useEffect } from "react"; + +import { Button } from "@/components/ui/button"; + +export default function ErrorPage({ + error, + retry, +}: { + error: Error & { digest?: string }; + retry: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+

This page couldn't load

+

+ The server or the connection had a problem. Nothing you entered before this was lost. Check the + Wi-Fi and try again. +

+ +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 5c60321..a9cad48 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,6 +8,8 @@ --ink: #14213d; --signal: #2ec4b6; --alert: #e63946; + /* Alert darkened to pass 4.5:1 as text on white, and as a background under white text. */ + --alert-strong: #c1121f; } /* Expose the tokens as Tailwind utilities: bg-stage, text-ink, font-display, ... */ @@ -18,6 +20,7 @@ --color-ink: var(--ink); --color-signal: var(--signal); --color-alert: var(--alert); + --color-alert-strong: var(--alert-strong); --font-display: var(--font-big-shoulders), ui-sans-serif, system-ui, sans-serif; --font-sans: var(--font-atkinson), ui-sans-serif, system-ui, sans-serif; } diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index 74fc8e3..1fe9644 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -30,7 +30,7 @@ export function LoginForm({ initialError }: { initialError: string | null }) {

{state.status === "sent" && {state.message}} - {error && {error}} + {error && {error}}

); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..4909cc6 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; + +import { buttonClass } from "@/components/ui/button"; + +export default function NotFound() { + return ( +
+

Page not found

+

+ That link doesn't lead anywhere. If the host sent it to you, ask them for a new one. +

+ + Go to the start page + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d81d52d..4474f14 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,32 +1,24 @@ +import Link from "next/link"; + +import { buttonClass } from "@/components/ui/button"; import { publicEnv } from "@/lib/env.public"; -const tokens = [ - { name: "Stage", className: "bg-stage" }, - { name: "Floodlight", className: "bg-floodlight" }, - { name: "Chalk", className: "bg-chalk" }, - { name: "Ink", className: "bg-ink" }, - { name: "Signal", className: "bg-signal" }, - { name: "Alert", className: "bg-alert" }, -]; - -// Placeholder home for Phase 0: proves fonts, tokens and env loading work. export default function Home() { return ( -
+
-

- {publicEnv.NEXT_PUBLIC_APP_NAME} -

-

Scaffold is running. Admin, scoreboard and judge pages come next.

+

{publicEnv.NEXT_PUBLIC_APP_NAME}

+

+ Live scoring for game nights: a control room for the host, a scoreboard for the projector and + links for judges' phones. +

-
    - {tokens.map((token) => ( -
  • -
    - {token.name} -
  • - ))} -
+
+ + Host sign-in + +
+

Judging or watching the scoreboard? Use the link or QR code the host gave you.

); } diff --git a/src/components/admin/bonus-panel.tsx b/src/components/admin/bonus-panel.tsx index e5d330f..5124c3b 100644 --- a/src/components/admin/bonus-panel.tsx +++ b/src/components/admin/bonus-panel.tsx @@ -113,7 +113,7 @@ export function BonusPanel({ view }: { view: LiveView }) { {adjustments.map((adjustment) => (
  • - + {signed(adjustment.points)} {" "} {teamName(adjustment.teamId)} diff --git a/src/components/admin/control-room.tsx b/src/components/admin/control-room.tsx index c5be641..09ced67 100644 --- a/src/components/admin/control-room.tsx +++ b/src/components/admin/control-room.tsx @@ -81,7 +81,10 @@ export function ControlRoom({ view }: { view: LiveView }) {
    - + ← Setup

    {event.name} · control room

    diff --git a/src/components/admin/error-text.tsx b/src/components/admin/error-text.tsx index 78781c5..eb4558f 100644 --- a/src/components/admin/error-text.tsx +++ b/src/components/admin/error-text.tsx @@ -1,6 +1,6 @@ export function ErrorText({ children }: { children: string | null }) { return ( -

    +

    {children}

    ); diff --git a/src/components/admin/judged-panel.tsx b/src/components/admin/judged-panel.tsx index 693e1ea..0131a63 100644 --- a/src/components/admin/judged-panel.tsx +++ b/src/components/admin/judged-panel.tsx @@ -93,7 +93,7 @@ export function JudgedPanel({ ✓ all scored ) : scored === 0 ? ( - + ✗ nothing scored ) : ( diff --git a/src/components/admin/judges-editor.tsx b/src/components/admin/judges-editor.tsx index 6b581ca..1016907 100644 --- a/src/components/admin/judges-editor.tsx +++ b/src/components/admin/judges-editor.tsx @@ -30,7 +30,7 @@ export function JudgesEditor({ eventId, game }: { eventId: string; game: GameWit
      {game.judges.map((judge) => (
    • - + {judge.name} {judge.source === "open_link" && ( joined via open link diff --git a/src/components/admin/scoreboard-panel.tsx b/src/components/admin/scoreboard-panel.tsx index 622009f..c7582b1 100644 --- a/src/components/admin/scoreboard-panel.tsx +++ b/src/components/admin/scoreboard-panel.tsx @@ -84,7 +84,7 @@ export function ScoreboardPanel({ view }: { view: LiveView }) { className={`min-h-14 rounded-md border px-1 text-sm font-bold disabled:opacity-60 ${ selected ? visibility === "hidden" - ? "border-alert bg-alert text-white" + ? "border-alert-strong bg-alert-strong text-white" : "border-ink bg-ink text-white" : "border-ink/25 bg-white hover:bg-ink/5" }`} diff --git a/src/components/admin/scoring-grid.tsx b/src/components/admin/scoring-grid.tsx index ade928f..ce532bb 100644 --- a/src/components/admin/scoring-grid.tsx +++ b/src/components/admin/scoring-grid.tsx @@ -118,7 +118,7 @@ export function ScoringGrid({ ) : ( <> {formatScore(cell.judgeAverage)} {" "} @@ -128,7 +128,7 @@ export function ScoringGrid({ )} {value !== null && ( - + Override {formatScore(value)} )} diff --git a/src/components/admin/share-panel.test.ts b/src/components/admin/share-panel.test.ts new file mode 100644 index 0000000..90375d8 --- /dev/null +++ b/src/components/admin/share-panel.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/env.public", () => ({ publicEnv: { NEXT_PUBLIC_SITE_URL: "https://gamenight.example" } })); + +const { judgeLinksText } = await import("./share-panel"); + +const judge = (name: string, token: string, revoked = false) => + ({ id: token, game_id: "g", name, token, revoked, source: "nominated", created_at: "" }) as const; + +describe("judgeLinksText", () => { + it("lists one line per active judge, skipping revoked ones", () => { + const text = judgeLinksText({ + title: "Karaoke", + judges: [judge("Eben", "aaa"), judge("Gone", "bbb", true), judge("Melissa", "ccc")], + }); + expect(text).toBe( + "Karaoke — judge links\nEben: https://gamenight.example/j/aaa\nMelissa: https://gamenight.example/j/ccc", + ); + }); +}); diff --git a/src/components/admin/share-panel.tsx b/src/components/admin/share-panel.tsx new file mode 100644 index 0000000..a37ed11 --- /dev/null +++ b/src/components/admin/share-panel.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { publicEnv } from "@/lib/env.public"; +import type { GameWithCriteria } from "@/lib/queries/event"; + +import { ShareLink } from "./share-link"; + +const site = publicEnv.NEXT_PUBLIC_SITE_URL; + +/** Plain text for pasting into WhatsApp: one line per judge. */ +export function judgeLinksText(game: Pick): string { + const lines = game.judges + .filter((judge) => !judge.revoked) + .map((judge) => `${judge.name}: ${site}/j/${judge.token}`); + return [`${game.title} — judge links`, ...lines].join("\n"); +} + +/** Every link for the night in one place: the scoreboard, and each judged game's judges. */ +export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string; games: GameWithCriteria[] }) { + const judgedGames = games.filter((game) => game.scoring_mode === "judged"); + const localOnly = /\/\/(localhost|127\.0\.0\.1)/.test(site); + + return ( +
      + {localOnly && ( +

      + These links point at {site}, which only works on this computer. Phones can open + them once the site is deployed and NEXT_PUBLIC_SITE_URL is set to its address. +

      + )} + +
      +

      Scoreboard (projector)

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

      No judged games yet. Set a game to “Judged” to give judges their own links.

      + ) : ( + judgedGames.map((game) => { + const activeJudges = game.judges.filter((judge) => !judge.revoked); + return ( +
      +
      +

      {game.title}

      + {activeJudges.length > 0 && } +
      + {activeJudges.length === 0 && !game.open_link_enabled && ( +

      No judges yet. Open this game below to add judges or turn on its open link.

      + )} +
        + {activeJudges.map((judge) => ( +
      • + {judge.name} + +
      • + ))} + {game.open_link_enabled && ( +
      • + Open link (anyone, enters their name) + +
      • + )} +
      +
      + ); + }) + )} +
      + ); +} + +function CopyAllButton({ text }: { text: string }) { + const [state, setState] = useState<"idle" | "copied" | "failed">("idle"); + + return ( + <> + + {state === "failed" && ( +
      +

      Couldn't copy automatically. Select the text below.

      +
      {text}
      +
      + )} + + ); +} diff --git a/src/components/judge/judge-view.tsx b/src/components/judge/judge-view.tsx index 5c0626c..b9b72e1 100644 --- a/src/components/judge/judge-view.tsx +++ b/src/components/judge/judge-view.tsx @@ -142,7 +142,7 @@ export function JudgeView({ token, initial }: { token: string; initial: JudgePay {isSaving(inTeam) ? ( "Saving…" ) : error ? ( - {error} + {error} ) : scored > 0 ? ( `Saved ✓${scored < payload.criteria.length ? ` · ${scored} of ${payload.criteria.length} scored` : ""}` ) : ( diff --git a/src/components/judge/open-link-form.tsx b/src/components/judge/open-link-form.tsx index 619c55c..8da355f 100644 --- a/src/components/judge/open-link-form.tsx +++ b/src/components/judge/open-link-form.tsx @@ -88,7 +88,7 @@ export function OpenLinkForm({ openToken, gameTitle }: { openToken: string; game -

      +

      {error}

      diff --git a/src/components/scoreboard/scoreboard.tsx b/src/components/scoreboard/scoreboard.tsx index 25ecdb4..4fc11ba 100644 --- a/src/components/scoreboard/scoreboard.tsx +++ b/src/components/scoreboard/scoreboard.tsx @@ -143,7 +143,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo