import React, { useEffect, useMemo, useRef, useState } from "react";
/**
* VJL4 Finals Bracket Simulator (React, system fonts, black/red/white theme)
* ------------------------------------------------------------------------
* Fix for "SyntaxError: /: Unexpected token (1:0)":
* - The Canvas file was of type code/react but contained a full HTML document.
* - Replaced with a proper React component export so the runtime parses JS/JSX, not HTML.
*
* Notes
* - 100% client-side; works on Cloudflare Pages.
* - System font stack only (no web fonts).
* - Responsive layout (1–4 columns depending on width).
* - Confetti surprise when a Grand Final winner is picked (reduced‑motion aware).
*
* Seeds are taken from the final VJL4 ladder after Round 18.
*/
// ---------- Pure bracket helpers (tested below) ----------
export function computeParticipants(seeds, picks) {
// seeds: {1..5: team}
// picks: { qf, ef, ms, sf, pf, gf } = winners user selected
const qf = { a: seeds[2], b: seeds[3], winner: picks.qf || null };
const ef = { a: seeds[4], b: seeds[5], winner: picks.ef || null };
const qfLoser = qf.winner
? qf.winner === seeds[2]
? seeds[3]
: seeds[2]
: null;
const ms = { a: seeds[1], b: qf.winner || null, winner: picks.ms || null };
const sf = { a: qfLoser || null, b: ef.winner || null, winner: picks.sf || null };
const msLoser = ms.winner && ms.a && ms.b ? (ms.winner === ms.a ? ms.b : ms.a) : null;
const pf = { a: msLoser || null, b: sf.winner || null, winner: picks.pf || null };
const gf = { a: ms.winner || null, b: pf.winner || null, winner: picks.gf || null };
return { qf, ef, ms, sf, pf, gf };
}
export function clearDownstream(picks, changedKey) {
const out = { ...picks };
if (changedKey === "qf") out.ms = out.sf = out.pf = out.gf = null;
if (changedKey === "ef") out.sf = out.pf = out.gf = null;
if (changedKey === "ms") out.pf = out.gf = null;
if (changedKey === "sf") out.pf = out.gf = null;
if (changedKey === "pf") out.gf = null;
return out;
}
// ---------- UI bits ----------
const theme = {
black: "#111111",
red: "#e11d48",
redDark: "#b91c1c",
border: "#e5e7eb",
};
function Card({ children, className = "" }) {
return (
{children}
);
}
function SectionTitle({ children }) {
return (
{children}
);
}
function TeamButton({ team, selected, disabled, onClick }) {
const isPlaceholder = !team;
const classes = [
"w-full text-center select-none rounded-xl px-3 py-2 border font-semibold transition",
];
if (selected) {
classes.push("text-white");
}
return (
);
}
function MatchCard({ title, a, b, winner, onPick, disabled }) {
return (
{title}
onPick(a)} />
onPick(b)} />
);
}
function SeedsBar({ seeds }) {
return (
{[1, 2, 3, 4, 5].map((s) => (
))}
);
}
function HeaderBar() {
return (
);
}
function Controls({ onAuto, onReset }) {
return (
);
}
// ---------- Confetti (no libs) ----------
function useConfetti() {
const canvasRef = useRef(null);
const rafRef = useRef(null);
const particlesRef = useRef([]);
const timerRef = useRef(null);
const start = () => {
const prefersReduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (prefersReduced) {
// Minimal flash effect
document.body.animate([{ background: "rgba(255,255,255,0)" }, { background: "rgba(255,255,255,.6)" }, { background: "rgba(255,255,255,0)" }], {
duration: 800,
easing: "ease",
});
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
const colors = ["#000000", "#ffffff", theme.red];
particlesRef.current = Array.from({ length: 240 }).map(() => ({
x: Math.random() * canvas.width,
y: -10 - Math.random() * 50,
vx: (Math.random() - 0.5) * 2.5,
vy: 2 + Math.random() * 3,
size: 4 + Math.random() * 6,
rot: Math.random() * Math.PI,
vr: (Math.random() - 0.5) * 0.2,
color: colors[Math.floor(Math.random() * colors.length)],
shape: Math.random() < 0.5 ? "rect" : "tri",
}));
const draw = (p) => {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
if (p.shape === "rect") {
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
} else {
ctx.beginPath();
ctx.moveTo(0, -p.size / 1.2);
ctx.lineTo(-p.size / 2, p.size / 2);
ctx.lineTo(p.size / 2, p.size / 2);
ctx.closePath();
ctx.fill();
}
ctx.restore();
};
const loop = () => {
rafRef.current = requestAnimationFrame(loop);
ctx.clearRect(0, 0, canvas.width, canvas.height);
particlesRef.current.forEach((p) => {
p.x += p.vx;
p.y += p.vy;
p.rot += p.vr;
if (p.y > canvas.height + 20) {
p.y = -10;
p.x = Math.random() * canvas.width;
}
draw(p);
});
};
canvas.style.display = "block";
loop();
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => stop(), 3500);
const onResize = () => resize();
window.addEventListener("resize", onResize, { passive: true });
// cleanup hook when stopped
const stop = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
particlesRef.current = [];
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
window.removeEventListener("resize", onResize);
};
// expose stop on ref for external call
confettiStop.current = stop;
};
const confettiStop = useRef(() => {});
const stop = () => confettiStop.current?.();
return { canvasRef, start, stop };
}
// ---------- Tests ----------
function runTests() {
const details = [];
let pass = true;
// Test 1: QF2 participants depend on QF1 winner
{
const seeds = { 1: "A", 2: "B", 3: "C", 4: "D", 5: "E" };
const picks = { qf: "B", ef: null, ms: null, sf: null, pf: null, gf: null };
const P = computeParticipants(seeds, picks);
if (P.ms.a !== "A" || P.ms.b !== "B") {
pass = false; details.push("Test1: QF2 (MS) should be A vs B");
} else { details.push("Test1: ✓ QF2 (MS) participants A vs B"); }
}
// Test 2: SF is L(QF1) vs W(EF)
{
const seeds = { 1: "A", 2: "B", 3: "C", 4: "D", 5: "E" };
const picks = { qf: "C", ef: "D", ms: null, sf: null, pf: null, gf: null };
const P = computeParticipants(seeds, picks);
if (P.sf.a !== "B" || P.sf.b !== "D") {
pass = false; details.push("Test2: SF should be B vs D");
} else { details.push("Test2: ✓ SF participants B vs D"); }
}
// Test 3: PF is L(MS) vs W(SF)
{
const seeds = { 1: "A", 2: "B", 3: "C", 4: "D", 5: "E" };
const picks = { qf: "B", ef: "D", ms: "A", sf: "D", pf: null, gf: null };
const P = computeParticipants(seeds, picks);
if (P.pf.a !== "B" || P.pf.b !== "D") {
pass = false; details.push("Test3: PF should be B vs D (L(MS)=B, W(SF)=D)");
} else { details.push("Test3: ✓ PF participants B vs D"); }
}
// Test 4: Changing QF clears downstream
{
const out = clearDownstream({ qf: "B", ef: "D", ms: "A", sf: "D", pf: "B", gf: "A" }, "qf");
if (out.ms !== null || out.sf !== null || out.pf !== null || out.gf !== null) {
pass = false; details.push("Test4: Changing QF must clear MS/SF/PF/GF");
} else { details.push("Test4: ✓ Downstream cleared on QF change"); }
}
return { pass, details };
}
function TestPanel() {
const [res, setRes] = useState(null);
useEffect(() => { setRes(runTests()); }, []);
return (
Dev: tests
{res ? (
<>
{res.pass ? "All tests passed" : "Some tests failed"}
{res.details.map((d, i) => (
- {d}
))}
>
) : (
Running…
)}
);
}
// ---------- Main Component ----------
export default function App() {
// Seeds from final ladder after Round 18
const SEEDS = useMemo(
() => ({ 1: "Kilsyth 3", 2: "Chelsea", 3: "Camberwell", 4: "Korumburra", 5: "Kilsyth 2" }),
[]
);
const [picks, setPicks] = useState({ qf: null, ef: null, ms: null, sf: null, pf: null, gf: null });
const P = useMemo(() => computeParticipants(SEEDS, picks), [SEEDS, picks]);
const { canvasRef, start: startConfetti, stop: stopConfetti } = useConfetti();
function setPick(key, winner) {
setPicks((prev) => clearDownstream({ ...prev, [key]: prev[key] === winner ? null : winner }, key));
}
function autoSim() {
const rnd = (a, b) => (Math.random() < 0.55 ? a : b);
const next = { qf: null, ef: null, ms: null, sf: null, pf: null, gf: null };
next.qf = rnd(SEEDS[2], SEEDS[3]);
next.ef = rnd(SEEDS[4], SEEDS[5]);
const msA = SEEDS[1];
const msB = next.qf; // requires QF winner
next.ms = rnd(msA, msB);
const qfLoser = next.qf === SEEDS[2] ? SEEDS[3] : SEEDS[2];
next.sf = rnd(qfLoser, next.ef);
const msLoser = next.ms === msA ? msB : msA;
next.pf = rnd(msLoser, next.sf);
next.gf = rnd(next.ms, next.pf);
setPicks(next);
}
function resetAll() {
stopConfetti();
setPicks({ qf: null, ef: null, ms: null, sf: null, pf: null, gf: null });
}
// trigger confetti when GF winner is set
useEffect(() => {
if (picks.gf) startConfetti();
}, [picks.gf]);
return (
U14 Boys VJL4 Finals Bracket Simulator (2025)
Finals commence on Friday, 22 August 2025. Click teams to advance.
setPick("qf", t)}
/>
setPick("ef", t)}
/>
setPick("ms", t)}
disabled={!P.ms.a || !P.ms.b}
/>
setPick("sf", t)}
disabled={!P.sf.a || !P.sf.b}
/>
setPick("pf", t)}
disabled={!P.pf.a || !P.pf.b}
/>
setPick("gf", t)}
disabled={!P.gf.a || !P.gf.b}
/>
Champion: {picks.gf || "TBD"}
{/* Confetti Canvas */}
);
}