social overhaul
This commit is contained in:
@@ -38,12 +38,14 @@
|
||||
"supertokens-web-js": "^0.15.0",
|
||||
"twilio": "^5.8.0",
|
||||
"vaul": "^1.1.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.0.15",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
@@ -614,6 +616,8 @@
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -632,6 +636,8 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
@@ -662,6 +668,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"bn.js": ["bn.js@4.12.5", "", {}, "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||
|
||||
"browser-image-compression": ["browser-image-compression@2.0.2", "", { "dependencies": { "uzip": "0.20201231.0" } }, "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw=="],
|
||||
@@ -880,6 +888,8 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="],
|
||||
@@ -888,6 +898,8 @@
|
||||
|
||||
"imask": ["imask@7.6.1", "", { "dependencies": { "@babel/runtime-corejs3": "^7.24.4" } }, "sha512-sJlIFM7eathUEMChTh9Mrfw/IgiWgJqBKq2VNbyXvBZ7ev/IlO6/KQTKlV/Fm+viQMLrFLG/zCuudrLIwgK2dg=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
@@ -1048,6 +1060,8 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -1238,6 +1252,8 @@
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
|
||||
@@ -1404,6 +1420,8 @@
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
@@ -1706,6 +1724,8 @@
|
||||
|
||||
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
@@ -1924,6 +1944,8 @@
|
||||
|
||||
"node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"web-push/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Side Bets — Design Spec (v1, locked)
|
||||
|
||||
**Status:** design locked, not yet built. Sequenced for Wave 3 (after player score reporting + the home "Upcoming" surface; ideally after web push for bet-open nudges).
|
||||
|
||||
No real money, ever. This is an in-tournament engagement layer.
|
||||
|
||||
## Currency
|
||||
- Each player gets **10 🪙 tokens fresh at the start of every tournament**. No carryover, **no seasons** (this app has no season concept).
|
||||
- **Whole numbers only.**
|
||||
- **No hard bust.** If you hit 0 tokens with matches remaining, you get a **1-token "comeback" bet each remaining match** so you're never fully benched. Not abusable — comeback stakes are smaller than the 10 you started with, so busting on purpose is strictly worse.
|
||||
|
||||
## When you can bet
|
||||
- A match's book **opens when the match is *set*** (both teams populated → status `ready`).
|
||||
- The book **closes the instant the admin *starts* the match** (status `started`) — same moment walkouts/announcements play. No bets after start.
|
||||
- This dovetails with score reporting, which only begins once a match is `started`.
|
||||
|
||||
## Bet type 1 — Winner (social pool / parimutuel)
|
||||
The core, social bet. You bet against your friends, not the house.
|
||||
|
||||
- Pick the winning team. Stake `1..(your tokens)`.
|
||||
- All stakes on the match form **one pot**. On finalize, everyone who picked the actual winner **splits the whole pot proportional to their stake**; wrong picks forfeit their tokens to the pot.
|
||||
- **Integer payout rule:** floor each winner's proportional share, then hand the leftover tokens out one-each to the largest fractional remainders (tie-break: larger stake, then earlier bet). Guarantees whole tokens and exact pot conservation.
|
||||
- **House floor:** a correct bet always returns **at least ~1.5× the stake** even if nobody took the other side, so thin/late pools still feel worth it. (Tunable.)
|
||||
- **Upset dynamics are automatic:** betting the less-popular team pays more because fewer winners split a bigger pot — no odds engine, no stats needed.
|
||||
- **Edge cases:**
|
||||
- Everyone picked the same team → no losers → **all stakes refunded** (no action).
|
||||
- Nobody picked the winner → **everyone refunded**.
|
||||
- Solo bettor → the **house floor** applies (this is exactly what it's for).
|
||||
- **Live projected payout:** before close, show "if you're right you'd win ~X", ticking up via SSE as friends pile in. Final pot locks at match start.
|
||||
|
||||
## Bet type 2 — Overtime long shots (house-backed, fixed payout)
|
||||
NOT a pool — you bet against a fixed multiplier, so including these does **not** thin the Winner pool. Cumulative "reaches at least N overtimes" tiers, keyed off the match `ot_count` field (the app tracks multiple OTs):
|
||||
|
||||
| Bet | Wins if | Payout |
|
||||
|---|---|---|
|
||||
| Reaches OT | `ot_count ≥ 1` | **2×** |
|
||||
| Reaches Double OT | `ot_count ≥ 2` | **4×** |
|
||||
| Reaches Triple OT | `ot_count ≥ 3` | **8×** |
|
||||
|
||||
Each is an independent yes/no long shot; **each additional OT doubles the payout** (memorable rule, jackpot feel for triple OT). Multipliers are tunable once real OT frequency is observed.
|
||||
|
||||
## Leaderboard & payoff
|
||||
- **Per-tournament Side Bets leaderboard**, ranked by token count; tie-break by number of correct bets (so a hoarder who never bet ranks below active players at the same count).
|
||||
- A few **badges** (global, like existing badges): e.g. "Called the Upset", "House Money", "Stone Cold Lock", "Degenerate" (most all-ins).
|
||||
|
||||
## Data model (proposed — finalize at build)
|
||||
- New `bets` collection: `{ tournament, match, player, market: "winner"|"ot1"|"ot2"|"ot3", pick (team id for winner), stake, status: pending|won|lost|refunded, payout }`.
|
||||
- **Settlement hooks into match finalization** (`finalizeMatch` / the `match` SSE): compute Winner pool splits + house-backed OT results, write payouts, update balances.
|
||||
- Per-(player, tournament) balance: start 10 − stakes + payouts (materialized or derived).
|
||||
|
||||
## UI/UX
|
||||
- Bettable matches surface on the home **Upcoming** carousel (Wave 2) + the match card/dock.
|
||||
- **Bet slip sheet:** choose market, stake slider, live projected payout.
|
||||
- Win/loss **reveal animation** on settle; the reaction rail already exists next to matches.
|
||||
- **Side Bets leaderboard screen** — mirror the existing predictions leaderboard component.
|
||||
|
||||
## Reuses / dependencies
|
||||
- SSE bus, the predictions leaderboard pattern, the badges system.
|
||||
- Depends on: score reporting (done), home Upcoming carousel (Wave 2), web push (Wave 2) for "betting's open on the Final" nudges.
|
||||
|
||||
## Side Bets on the home screen (reserved slot)
|
||||
The Wave 2 home redesign **leaves a labeled slot for this** — build it here later:
|
||||
- A compact **"Your Side Bets" card** near the top of the **Upcoming** section, showing your 🪙 token balance, number of open (unsettled) bets, and a link to the Side Bets leaderboard. Hidden if the tournament isn't bettable (no set-but-unstarted matches) or the user has no involvement.
|
||||
- On each card in the **Upcoming** carousel (matches that are `ready`, not yet `started`), a **"Bet" affordance** that opens the bet-slip sheet. This is the primary entry point — you bet on matches that are about to start.
|
||||
- Once a match is `started`, its bets are locked; the card flips to showing the pool result live as it settles.
|
||||
- The redesign should reserve this vertical space and (in code) leave a clear `{/* SIDE BETS SLOT — see docs/side-bets.md */}` marker where the card mounts, so wiring it in later is a drop-in.
|
||||
|
||||
## Implementation notes for a fresh agent (build this cold)
|
||||
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/side-bets.md` (this file) fully first, then:
|
||||
- **Server fns:** follow the `createServerFn(...).validator(zod).middleware([...]).handler(...)` + `toServerResult` pattern. Player-facing (non-admin) fns use `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])` and resolve the caller via `getPlayerByAuthId(context.userAuthId)` — copy `toggleMatchReaction` in `src/features/matches/server.ts` as the template. All DB access is server-side via `pbAdmin`; collections have null rules.
|
||||
- **Settlement:** hook into `finalizeMatch` in `src/features/matches/server.ts` (the single match-finalization path — admin `endMatch` and player `confirmMatchScore` both call it). When a match finalizes, settle its Winner pool (integer largest-remainder split + house floor) and the OT long shots. Emit an SSE event so balances/leaderboard update live.
|
||||
- **Bet open/close** is derived from match `status`: bettable while `ready`, locked at `started` (identical trigger to score reporting).
|
||||
- **Migration:** mimic an existing `pb_migrations/*_created_*.js` (e.g. `1784007613_created_predictions.js`) for the new `bets` collection — plain `migrate((app)=>{...})` with an up + down. Teams collection id `pbc_1568971955`, players `pbc_3072146508`, matches `pbc_2541054544`, tournaments `pbc_340646327`.
|
||||
- **Client data:** use the `useServerQuery` / `useServerMutation` hooks (`src/lib/tanstack-query/hooks/*`) and the query-key convention seen in `src/features/predictions/queries.ts`. Add the new event type to `src/lib/events/emitter.ts` + handle it in `src/hooks/use-server-events.ts` (invalidate side-bets keys, mirroring how `match`/`reaction` are handled).
|
||||
- **Leaderboard UI:** mirror `src/features/predictions/components/prediction-leaderboard.tsx` and its route `src/app/routes/_authed/tournaments/$id.predictions.tsx` for a `$id.side-bets.tsx` route.
|
||||
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only.
|
||||
- **New feature dir:** `src/features/side-bets/{types.ts, server.ts, queries.ts, utils.ts, components/}` — keep pure logic (pool split, house floor, OT payout) in `utils.ts` with unit-testable functions, like `src/features/predictions/utils.ts`.
|
||||
- **Design system:** Mantine 8 + the app theme; press feedback via the `flxn-press` theme; tasteful motion only (iOS-like easing, 120–350ms) — match neighboring components, don't invent a new look.
|
||||
|
||||
## Tunables to revisit after first live use
|
||||
- House floor multiplier (default **1.5×**).
|
||||
- OT multipliers (**2× / 4× / 8×**).
|
||||
- Comeback token amount (**1**).
|
||||
- Whether an all-agreed pool refunds or voids.
|
||||
@@ -43,12 +43,14 @@
|
||||
"supertokens-web-js": "^0.15.0",
|
||||
"twilio": "^5.8.0",
|
||||
"vaul": "^1.1.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(24, new Field({
|
||||
"hidden": false,
|
||||
"id": "number9101010101",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "reported_home_cups",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(25, new Field({
|
||||
"hidden": false,
|
||||
"id": "number9202020202",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "reported_away_cups",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(26, new Field({
|
||||
"hidden": false,
|
||||
"id": "number9303030303",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "reported_ot_count",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(27, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_1568971955",
|
||||
"hidden": false,
|
||||
"id": "relation9404040404",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "reported_by_team",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(28, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation9505050505",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "reported_by_player",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number9101010101")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number9202020202")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number9303030303")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation9404040404")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation9505050505")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation_player_push",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_endpoint_push",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "endpoint",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_p256dh_push",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "p256dh",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_auth_push",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "auth",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_user_agent_push",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "user_agent",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_push_subscriptions",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_push_subscriptions_endpoint` ON `push_subscriptions` (`endpoint`)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "push_subscriptions",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_push_subscriptions");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Hand-written Web Push handlers, imported into the Workbox-generated SW via
|
||||
// `importScripts` (see scripts/generate-sw.mjs). Workbox never overwrites this
|
||||
// file — it's copied verbatim from /public and pulled in at SW startup.
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch (e) {
|
||||
data = { title: 'Notification', body: event.data ? event.data.text() : '' };
|
||||
}
|
||||
|
||||
const title = data.title || 'Flexxon';
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '/icon-192x192.png',
|
||||
badge: '/icon-192x192.png',
|
||||
tag: data.tag || undefined,
|
||||
data: { url: data.url || '/' },
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const targetUrl = (event.notification.data && event.notification.data.url) || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Focus an existing tab if one is already open, else open a new one.
|
||||
for (const client of clientList) {
|
||||
try {
|
||||
const clientUrl = new URL(client.url);
|
||||
const target = new URL(targetUrl, self.location.origin);
|
||||
if (clientUrl.pathname === target.pathname && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore malformed URLs
|
||||
}
|
||||
}
|
||||
for (const client of clientList) {
|
||||
if ('focus' in client) {
|
||||
client.navigate(targetUrl);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,13 @@ const { count, size, warnings } = await generateSW({
|
||||
'icon-512x512.png',
|
||||
'site.webmanifest',
|
||||
'styles.css',
|
||||
'push-handlers.js',
|
||||
],
|
||||
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into
|
||||
// the generated SW at startup. importScripts runs them in the SW global
|
||||
// scope, so their `push` / `notificationclick` listeners compose with
|
||||
// Workbox's precache + runtime caching setup below.
|
||||
importScripts: ['/push-handlers.js'],
|
||||
navigateFallback: null,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
|
||||
// Usage: bun run scripts/make-admin.ts <phone> [--revoke]
|
||||
import "dotenv/config";
|
||||
import SuperTokens from "supertokens-node";
|
||||
import Session from "supertokens-node/recipe/session";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import UserRoles from "supertokens-node/recipe/userroles";
|
||||
|
||||
SuperTokens.init({
|
||||
framework: "custom",
|
||||
supertokens: {
|
||||
connectionURI: process.env.SUPERTOKENS_URI || "http://localhost:3567",
|
||||
apiKey: process.env.SUPERTOKENS_API_KEY || undefined,
|
||||
},
|
||||
appInfo: {
|
||||
appName: "FLXN",
|
||||
apiDomain: "http://localhost:3000",
|
||||
websiteDomain: "http://localhost:3000",
|
||||
apiBasePath: "/api/auth",
|
||||
websiteBasePath: "/auth",
|
||||
},
|
||||
recipeList: [
|
||||
Passwordless.init({ contactMethod: "PHONE", flowType: "USER_INPUT_CODE" }),
|
||||
Session.init(),
|
||||
UserRoles.init(),
|
||||
],
|
||||
});
|
||||
|
||||
const raw = process.argv[2];
|
||||
const revoke = process.argv.includes("--revoke");
|
||||
if (!raw) {
|
||||
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const digits = raw.replace(/[^\d]/g, "");
|
||||
const candidates = Array.from(
|
||||
new Set([
|
||||
raw.startsWith("+") ? raw : null,
|
||||
digits.length === 10 ? `+1${digits}` : null,
|
||||
`+${digits}`,
|
||||
digits,
|
||||
].filter(Boolean) as string[])
|
||||
);
|
||||
|
||||
let user: { id: string } | undefined;
|
||||
let matched = "";
|
||||
for (const phoneNumber of candidates) {
|
||||
const users = await SuperTokens.listUsersByAccountInfo("public", { phoneNumber });
|
||||
if (users.length) {
|
||||
user = users[0];
|
||||
matched = phoneNumber;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.error(`No SuperTokens user found for phone (tried: ${candidates.join(", ")}).`);
|
||||
console.error("The user must have logged in at least once so their account exists.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await UserRoles.createNewRoleOrAddPermissions("Admin", []);
|
||||
|
||||
if (revoke) {
|
||||
const res = await UserRoles.removeUserRole("public", user.id, "Admin");
|
||||
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
|
||||
} else {
|
||||
const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
|
||||
console.log(
|
||||
`Granted Admin to ${matched} (user ${user.id}):`,
|
||||
res.status === "OK"
|
||||
? res.didUserAlreadyHaveRole
|
||||
? "already had it"
|
||||
: "added"
|
||||
: res.status
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Note: sign out and back in (or refresh the session) for the role to take effect.");
|
||||
process.exit(0);
|
||||
@@ -29,6 +29,9 @@ import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resum
|
||||
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
||||
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
||||
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
||||
import { Route as ApiPushUnsubscribeRouteImport } from './routes/api/push/unsubscribe'
|
||||
import { Route as ApiPushTestRouteImport } from './routes/api/push/test'
|
||||
import { Route as ApiPushSubscribeRouteImport } from './routes/api/push/subscribe'
|
||||
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||
@@ -149,6 +152,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
|
||||
path: '/api/spotify/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPushUnsubscribeRoute = ApiPushUnsubscribeRouteImport.update({
|
||||
id: '/api/push/unsubscribe',
|
||||
path: '/api/push/unsubscribe',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPushTestRoute = ApiPushTestRouteImport.update({
|
||||
id: '/api/push/test',
|
||||
path: '/api/push/test',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPushSubscribeRoute = ApiPushSubscribeRouteImport.update({
|
||||
id: '/api/push/subscribe',
|
||||
path: '/api/push/subscribe',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
||||
id: '/api/events/$',
|
||||
path: '/api/events/$',
|
||||
@@ -275,6 +293,9 @@ export interface FileRoutesByFullPath {
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||
'/api/push/test': typeof ApiPushTestRoute
|
||||
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
@@ -314,6 +335,9 @@ export interface FileRoutesByTo {
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||
'/api/push/test': typeof ApiPushTestRoute
|
||||
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
@@ -356,6 +380,9 @@ export interface FileRoutesById {
|
||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||
'/api/push/test': typeof ApiPushTestRoute
|
||||
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
@@ -398,6 +425,9 @@ export interface FileRouteTypes {
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/push/subscribe'
|
||||
| '/api/push/test'
|
||||
| '/api/push/unsubscribe'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
@@ -437,6 +467,9 @@ export interface FileRouteTypes {
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/push/subscribe'
|
||||
| '/api/push/test'
|
||||
| '/api/push/unsubscribe'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
@@ -478,6 +511,9 @@ export interface FileRouteTypes {
|
||||
| '/_authed/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/push/subscribe'
|
||||
| '/api/push/test'
|
||||
| '/api/push/unsubscribe'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
@@ -509,6 +545,9 @@ export interface RootRouteChildren {
|
||||
ApiHealthRoute: typeof ApiHealthRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
||||
ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
|
||||
ApiPushTestRoute: typeof ApiPushTestRoute
|
||||
ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
|
||||
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
||||
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
||||
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
||||
@@ -662,6 +701,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/push/unsubscribe': {
|
||||
id: '/api/push/unsubscribe'
|
||||
path: '/api/push/unsubscribe'
|
||||
fullPath: '/api/push/unsubscribe'
|
||||
preLoaderRoute: typeof ApiPushUnsubscribeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/push/test': {
|
||||
id: '/api/push/test'
|
||||
path: '/api/push/test'
|
||||
fullPath: '/api/push/test'
|
||||
preLoaderRoute: typeof ApiPushTestRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/push/subscribe': {
|
||||
id: '/api/push/subscribe'
|
||||
path: '/api/push/subscribe'
|
||||
fullPath: '/api/push/subscribe'
|
||||
preLoaderRoute: typeof ApiPushSubscribeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/events/$': {
|
||||
id: '/api/events/$'
|
||||
path: '/api/events/$'
|
||||
@@ -874,6 +934,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
||||
ApiPushSubscribeRoute: ApiPushSubscribeRoute,
|
||||
ApiPushTestRoute: ApiPushTestRoute,
|
||||
ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
|
||||
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
||||
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
||||
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
||||
|
||||
@@ -44,10 +44,10 @@ export const Route = createRootRouteWithContext<{
|
||||
content:
|
||||
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
|
||||
},
|
||||
{ name: 'description', content: 'Amicus meus madidus' },
|
||||
{ name: 'description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
||||
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
||||
{ property: 'og:title', content: 'FLXN' },
|
||||
{ property: 'og:description', content: 'Amicus meus madidus' },
|
||||
{ property: 'og:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
||||
{ property: 'og:url', content: 'https://flexxon.app' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:site_name', content: 'FLXN' },
|
||||
@@ -58,7 +58,7 @@ export const Route = createRootRouteWithContext<{
|
||||
{ property: 'og:locale', content: 'en_US' },
|
||||
{ name: 'twitter:card', content: 'summary' },
|
||||
{ name: 'twitter:title', content: 'FLXN' },
|
||||
{ name: 'twitter:description', content: 'Amicus meus madidus' },
|
||||
{ name: 'twitter:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
||||
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||
|
||||
@@ -97,6 +97,16 @@ function RouteComponent() {
|
||||
) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const nextUpMatchId = useMemo(() => {
|
||||
const ready = (tournament.matches ?? [])
|
||||
.filter(
|
||||
(match) =>
|
||||
match.status === "ready" && match.home && match.away && !match.bye
|
||||
)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return ready[0]?.id;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const bracket: BracketData = useMemo(() => {
|
||||
if (!tournament.matches || tournament.matches.length === 0) {
|
||||
return { winners: [], losers: [] };
|
||||
@@ -146,11 +156,12 @@ function RouteComponent() {
|
||||
hasKnockoutBracket={knockoutBracketPopulated}
|
||||
isRegional={tournament.regional}
|
||||
groupConfig={tournament.group_config}
|
||||
nextUpMatchId={nextUpMatchId}
|
||||
/>
|
||||
<Divider />
|
||||
<div>
|
||||
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
||||
</div>
|
||||
</Stack>
|
||||
) : hasGroupStage ? (
|
||||
@@ -162,9 +173,10 @@ function RouteComponent() {
|
||||
hasKnockoutBracket={knockoutBracketPopulated}
|
||||
isRegional={tournament.regional}
|
||||
groupConfig={tournament.group_config}
|
||||
nextUpMatchId={nextUpMatchId}
|
||||
/>
|
||||
) : (
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
||||
)
|
||||
) : (
|
||||
tournament.regional === true ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Box, Title, Stack } from "@mantine/core";
|
||||
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
|
||||
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
|
||||
import { NotificationsSection } from "@/features/settings/components/notifications-section";
|
||||
import { SignOutIcon } from "@phosphor-icons/react";
|
||||
import ListLink from "@/components/list-link";
|
||||
|
||||
@@ -32,6 +33,7 @@ function RouteComponent() {
|
||||
<ColorSchemePicker />
|
||||
</Stack>
|
||||
</Box>
|
||||
<NotificationsSection />
|
||||
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/push/subscribe")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
const player = (context as any).player;
|
||||
if (!player) return json({ error: "No player for session" }, 401);
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const sub = payload?.subscription ?? payload;
|
||||
const endpoint: string | undefined = sub?.endpoint;
|
||||
const p256dh: string | undefined = sub?.keys?.p256dh;
|
||||
const auth: string | undefined = sub?.keys?.auth;
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
return json({ error: "Invalid subscription" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await pbAdmin.upsertPushSubscription({
|
||||
player: player.id,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
user_agent: request.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error("Push | subscribe failed", error);
|
||||
return json({ error: "Failed to store subscription" }, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { sendPushToPlayer } from "@/lib/push";
|
||||
import { isPushConfigured } from "@/lib/config";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/push/test")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ context }) => {
|
||||
const player = (context as any).player;
|
||||
if (!player) return json({ error: "No player for session" }, 401);
|
||||
|
||||
if (!isPushConfigured()) {
|
||||
return json({ error: "Push is not configured on the server" }, 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendPushToPlayer(player.id, {
|
||||
title: "Flexxon",
|
||||
body: "Test notification — push is working on this device.",
|
||||
url: "/settings",
|
||||
tag: "flexxon-test",
|
||||
});
|
||||
return json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error("Push | test send failed", error);
|
||||
return json({ error: "Failed to send test notification" }, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/push/unsubscribe")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
const player = (context as any).player;
|
||||
if (!player) return json({ error: "No player for session" }, 401);
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const endpoint: string | undefined =
|
||||
payload?.endpoint ?? payload?.subscription?.endpoint;
|
||||
if (!endpoint) return json({ error: "Missing endpoint" }, 400);
|
||||
|
||||
try {
|
||||
await pbAdmin.deletePushSubscriptionByEndpoint(endpoint);
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error("Push | unsubscribe failed", error);
|
||||
return json({ error: "Failed to remove subscription" }, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -16,9 +16,10 @@ interface BracketViewProps {
|
||||
};
|
||||
renderMatch?: (match: Match) => React.ReactNode;
|
||||
bottomOffset?: number;
|
||||
nextUpMatchId?: string;
|
||||
}
|
||||
|
||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => {
|
||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset, nextUpMatchId }) => {
|
||||
const height = useAppShellHeight();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const hasAutoScrolled = useRef(false);
|
||||
@@ -50,8 +51,9 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
||||
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
|
||||
(match) => !match.bye
|
||||
);
|
||||
const target =
|
||||
matches.find((match) => match.status === "started") ??
|
||||
const target = nextUpMatchId !== undefined
|
||||
? matches.find((match) => match.id === nextUpMatchId)
|
||||
: matches.find((match) => match.status === "started") ??
|
||||
matches.find((match) => match.status === "ready");
|
||||
if (!target) return;
|
||||
|
||||
@@ -78,7 +80,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
||||
? "auto"
|
||||
: "smooth",
|
||||
});
|
||||
}, [bracket]);
|
||||
}, [bracket, nextUpMatchId]);
|
||||
|
||||
return <Box pos="relative">
|
||||
<ScrollArea
|
||||
@@ -95,14 +97,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Winners Bracket
|
||||
</Text>
|
||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
|
||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
|
||||
</div>
|
||||
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Losers Bracket
|
||||
</Text>
|
||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
|
||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
|
||||
</div>
|
||||
)}
|
||||
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface BracketProps {
|
||||
renderMatch?: (match: Match) => React.ReactNode;
|
||||
onMatchTap?: (match: Match) => void;
|
||||
selectedMatchLid?: number | null;
|
||||
nextUpMatchId?: string;
|
||||
}
|
||||
|
||||
export const Bracket: React.FC<BracketProps> = ({
|
||||
@@ -24,6 +25,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
||||
renderMatch,
|
||||
onMatchTap,
|
||||
selectedMatchLid,
|
||||
nextUpMatchId,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
@@ -149,6 +151,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
||||
groupConfig={groupConfig}
|
||||
onTap={onMatchTap}
|
||||
selected={selectedMatchLid === match.lid}
|
||||
nextUpMatchId={nextUpMatchId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
|
||||
import { ActionIcon, Card, Flex, Text, Indicator, Stack } from "@mantine/core";
|
||||
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { MatchSlot } from "./match-slot";
|
||||
@@ -6,6 +6,8 @@ import { Match } from "@/features/matches/types";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { MatchForm } from "./match-form";
|
||||
import { MatchReport } from "./match-report";
|
||||
import toast from "@/lib/sonner";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { endMatch, startMatch } from "@/features/matches/server";
|
||||
@@ -25,6 +27,7 @@ interface MatchCardProps {
|
||||
};
|
||||
onTap?: (match: Match) => void;
|
||||
selected?: boolean;
|
||||
nextUpMatchId?: string;
|
||||
}
|
||||
|
||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
@@ -34,6 +37,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
groupConfig,
|
||||
onTap,
|
||||
selected,
|
||||
nextUpMatchId,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const editSheet = useSheet();
|
||||
@@ -41,6 +45,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
|
||||
const canTap = !!(onTap && match.home && match.away);
|
||||
|
||||
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
|
||||
|
||||
const homeSlot = useMemo(
|
||||
() => ({
|
||||
from: orders[match.home_from_lid],
|
||||
@@ -80,7 +86,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
);
|
||||
|
||||
const showEditButton = useMemo(
|
||||
() => showControls && match.status === "started",
|
||||
() =>
|
||||
showControls && (match.status === "started" || match.status === "ended"),
|
||||
[showControls, match.status]
|
||||
);
|
||||
|
||||
@@ -98,11 +105,21 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
|
||||
const end = useServerMutation({
|
||||
mutationFn: endMatch,
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(match.tournament.id),
|
||||
});
|
||||
editSheet.close();
|
||||
if (data?.downstreamReset) {
|
||||
toast.error(
|
||||
"Downstream matches were reset because the correction changed who advances."
|
||||
);
|
||||
}
|
||||
if (data?.groupEditAfterKnockout) {
|
||||
toast.error(
|
||||
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -195,10 +212,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
data: match.id,
|
||||
});
|
||||
|
||||
// Skip announcements for regional tournaments
|
||||
const isRegional = match.tournament?.regional === true;
|
||||
|
||||
// Play walkout sequence after starting the match (only for non-regional tournaments)
|
||||
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
|
||||
try {
|
||||
const homeTeam = match.home as Team;
|
||||
@@ -217,6 +232,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
|
||||
|
||||
return (
|
||||
<Stack gap={6} align="flex-end">
|
||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||
<Text
|
||||
c="dimmed"
|
||||
@@ -229,7 +245,17 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
>
|
||||
{match.order}
|
||||
</Text>
|
||||
<Flex align="stretch">
|
||||
<Flex
|
||||
align="stretch"
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-default)",
|
||||
boxShadow: isNextUp
|
||||
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
|
||||
: undefined,
|
||||
transition:
|
||||
"box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
}}
|
||||
>
|
||||
<Indicator
|
||||
inline
|
||||
processing={match.status === "started"}
|
||||
@@ -267,6 +293,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
? 'var(--mantine-primary-color-filled)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
boxShadow: 'var(--mantine-shadow-sm)',
|
||||
transition:
|
||||
'border-color 200ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
}}
|
||||
data-match-lid={match.lid}
|
||||
>
|
||||
@@ -336,7 +364,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
{showEditButton && (
|
||||
<Flex direction="column" justify="center" align="center">
|
||||
<ActionIcon
|
||||
color="blue"
|
||||
color={match.status === "ended" ? "gray" : "blue"}
|
||||
onClick={editSheet.open}
|
||||
loading={end.isPending}
|
||||
size="sm"
|
||||
@@ -364,5 +392,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
/>
|
||||
</Sheet>
|
||||
</Flex>
|
||||
|
||||
{!showControls && <MatchReport match={match} compact />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import TeamAvatar from "@/components/team-avatar";
|
||||
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
||||
import { MatchReport } from "./match-report";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
|
||||
@@ -149,6 +150,8 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
||||
ended={ended}
|
||||
/>
|
||||
|
||||
<MatchReport match={match} />
|
||||
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Suspense
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert, Button, Flex, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { CheckIcon, LightningIcon } from "@phosphor-icons/react";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import {
|
||||
useReportMatchScore,
|
||||
useConfirmMatchScore,
|
||||
useClearMatchReport,
|
||||
} from "@/features/matches/queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { MatchForm } from "./match-form";
|
||||
import toast from "@/lib/sonner";
|
||||
|
||||
const teamId = (t: Match["home"]): string | undefined =>
|
||||
!t ? undefined : typeof t === "string" ? t : t.id;
|
||||
|
||||
const teamPlayerIds = (t: Match["home"]): string[] =>
|
||||
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
|
||||
|
||||
const teamName = (t: Match["home"]): string | undefined =>
|
||||
t && typeof t !== "string" ? t.name : undefined;
|
||||
|
||||
interface MatchReportProps {
|
||||
match: Match;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
|
||||
const { user } = useAuth();
|
||||
const formSheet = useSheet();
|
||||
const [acknowledgedReport, setAcknowledgedReport] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const homeId = teamId(match.home);
|
||||
const onHome = !!user?.id && teamPlayerIds(match.home).includes(user.id);
|
||||
const onAway = !!user?.id && teamPlayerIds(match.away).includes(user.id);
|
||||
const isParticipant = onHome || onAway;
|
||||
|
||||
const hasPending =
|
||||
match.reported_by_team != null &&
|
||||
match.reported_home_cups != null &&
|
||||
match.reported_away_cups != null;
|
||||
|
||||
const reportedByHome = hasPending && match.reported_by_team === homeId;
|
||||
const reportingTeam = hasPending
|
||||
? reportedByHome
|
||||
? match.home
|
||||
: match.away
|
||||
: undefined;
|
||||
const opposingTeam = reportedByHome ? match.away : match.home;
|
||||
const reportedByMine =
|
||||
hasPending && ((reportedByHome && onHome) || (!reportedByHome && onAway));
|
||||
|
||||
const pendingSignature = hasPending
|
||||
? [
|
||||
match.reported_by_team,
|
||||
match.reported_home_cups,
|
||||
match.reported_away_cups,
|
||||
match.reported_ot_count ?? 0,
|
||||
].join(":")
|
||||
: null;
|
||||
|
||||
const sheetTakenOver =
|
||||
formSheet.isOpen &&
|
||||
isParticipant &&
|
||||
hasPending &&
|
||||
!reportedByMine &&
|
||||
pendingSignature !== acknowledgedReport;
|
||||
|
||||
const report = useReportMatchScore(user?.id, {
|
||||
onSuccess: (data: any) => {
|
||||
if (data?.finalized) {
|
||||
toast.success("Score confirmed 🍻");
|
||||
if (data.downstreamReset) {
|
||||
toast.error(
|
||||
"Downstream matches were reset because the correction changed who advances."
|
||||
);
|
||||
}
|
||||
if (data.groupEditAfterKnockout) {
|
||||
toast.error(
|
||||
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const confirm = useConfirmMatchScore({
|
||||
onSuccess: (data: any) => {
|
||||
if (data?.downstreamReset) {
|
||||
toast.error(
|
||||
"Downstream matches were reset because the correction changed who advances."
|
||||
);
|
||||
}
|
||||
if (data?.groupEditAfterKnockout) {
|
||||
toast.error(
|
||||
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const clear = useClearMatchReport();
|
||||
|
||||
const submitReport = useCallback(
|
||||
(data: { home_cups: number; away_cups: number; ot_count: number }) => {
|
||||
report.mutate({ data: { ...data, matchId: match.id } });
|
||||
formSheet.close();
|
||||
toast.success("Score submitted — waiting for the other team");
|
||||
},
|
||||
[report, match.id, formSheet.close]
|
||||
);
|
||||
|
||||
const submitConfirm = useCallback(() => {
|
||||
confirm.mutate({ data: { matchId: match.id } });
|
||||
formSheet.close();
|
||||
toast.success("Score confirmed 🍻");
|
||||
}, [confirm, match.id, formSheet.close]);
|
||||
|
||||
const submitClear = useCallback(() => {
|
||||
clear.mutate({ data: { matchId: match.id } });
|
||||
toast.success("Report cleared");
|
||||
}, [clear, match.id]);
|
||||
|
||||
const openForm = useCallback(() => {
|
||||
setAcknowledgedReport(pendingSignature);
|
||||
formSheet.open();
|
||||
}, [pendingSignature, formSheet.open]);
|
||||
|
||||
const dismissTakeover = useCallback(() => {
|
||||
setAcknowledgedReport(pendingSignature);
|
||||
}, [pendingSignature]);
|
||||
|
||||
if (match.status !== "started") return null;
|
||||
|
||||
const scoreText = hasPending
|
||||
? `${match.reported_home_cups}–${match.reported_away_cups}`
|
||||
: "";
|
||||
const size = compact ? "compact-xs" : "sm";
|
||||
|
||||
const formSheetEl = (
|
||||
<Sheet
|
||||
title={sheetTakenOver ? "Confirm Score" : "Report Score"}
|
||||
{...formSheet.props}
|
||||
>
|
||||
{sheetTakenOver ? (
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
icon={<LightningIcon size={18} weight="fill" />}
|
||||
title={`${teamName(reportingTeam) ?? "The other team"} just reported a score`}
|
||||
>
|
||||
Does this look right?
|
||||
</Alert>
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<Group justify="center" gap="md" wrap="nowrap">
|
||||
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ta="center" lineClamp={1}>
|
||||
{teamName(match.home) ?? "Home"}
|
||||
</Text>
|
||||
<Text fz={34} fw={700} lh={1.2}>
|
||||
{match.reported_home_cups}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text c="dimmed" fw={600}>
|
||||
–
|
||||
</Text>
|
||||
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ta="center" lineClamp={1}>
|
||||
{teamName(match.away) ?? "Away"}
|
||||
</Text>
|
||||
<Text fz={34} fw={700} lh={1.2}>
|
||||
{match.reported_away_cups}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
fullWidth
|
||||
leftSection={<CheckIcon size={14} weight="bold" />}
|
||||
onClick={submitConfirm}
|
||||
loading={confirm.isPending}
|
||||
>
|
||||
Confirm {scoreText}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
onClick={dismissTakeover}
|
||||
disabled={confirm.isPending}
|
||||
>
|
||||
Not right — enter our score
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<MatchForm
|
||||
match={match}
|
||||
onSubmit={submitReport}
|
||||
onCancel={formSheet.close}
|
||||
loading={report.isPending}
|
||||
/>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
if (isParticipant && !hasPending) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size={size}
|
||||
fullWidth={!compact}
|
||||
onClick={openForm}
|
||||
loading={report.isPending}
|
||||
>
|
||||
Report Score
|
||||
</Button>
|
||||
{formSheetEl}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPending) {
|
||||
if (reportedByMine) {
|
||||
return (
|
||||
<Flex
|
||||
direction={compact ? "column" : "row"}
|
||||
align={compact ? "stretch" : "center"}
|
||||
justify="space-between"
|
||||
gap={compact ? 4 : "xs"}
|
||||
>
|
||||
<Text size="xs" c="dimmed" lineClamp={2} style={{ minWidth: 0 }}>
|
||||
Waiting for {teamName(opposingTeam) ?? "the other team"} to confirm{" "}
|
||||
·{" "}
|
||||
<Text span fw={600} c="bright">
|
||||
{scoreText}
|
||||
</Text>
|
||||
</Text>
|
||||
<Button
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={submitClear}
|
||||
loading={clear.isPending}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (isParticipant) {
|
||||
return (
|
||||
<>
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{teamName(reportingTeam) ?? "Opponent"} reported{" "}
|
||||
<Text span fw={600} c="bright">
|
||||
{scoreText}
|
||||
</Text>
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size={size}
|
||||
leftSection={<CheckIcon size={12} weight="bold" />}
|
||||
onClick={submitConfirm}
|
||||
loading={confirm.isPending}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size={size}
|
||||
variant="default"
|
||||
onClick={openForm}
|
||||
disabled={confirm.isPending}
|
||||
>
|
||||
Not right
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
{formSheetEl}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
Pending: {scoreText} · reported by {teamName(reportingTeam) ?? "a team"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import GlitchAvatar from '@/components/glitch-avatar';
|
||||
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
||||
import { useCurrentTournament } from '@/features/tournaments/queries';
|
||||
import { AppShell, Flex, Paper, em, Title, Text, Stack } from '@mantine/core';
|
||||
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
|
||||
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
||||
import { TrophyIcon } from '@phosphor-icons/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
@@ -27,7 +27,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
style={{ transition: 'padding-top 0.1s ease' }}
|
||||
>
|
||||
<Paper
|
||||
shadow='md'
|
||||
p='md'
|
||||
w='100%'
|
||||
maw='375px'
|
||||
@@ -52,12 +51,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</GlitchAvatar>
|
||||
<Stack align='center' gap={2}>
|
||||
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
||||
<Text size='sm' c='dimmed' fs='italic' ta='center'>
|
||||
Amicus meus madidus
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{children}
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { getMatchesBetweenTeams, getMatchesBetweenPlayers } from "./server";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import {
|
||||
getMatchesBetweenTeams,
|
||||
getMatchesBetweenPlayers,
|
||||
reportMatchScore,
|
||||
confirmMatchScore,
|
||||
clearMatchReport,
|
||||
} from "./server";
|
||||
|
||||
export const matchKeys = {
|
||||
headToHeadTeams: (team1Id: string, team2Id: string) => ['matches', 'headToHead', 'teams', team1Id, team2Id] as const,
|
||||
@@ -28,3 +36,130 @@ export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enable
|
||||
...matchQueries.headToHeadPlayers(player1Id, player2Id),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const tournamentsRoot = { queryKey: ["tournaments"] as const };
|
||||
|
||||
const reportTeamId = (t: Match["home"]): string | undefined =>
|
||||
!t ? undefined : typeof t === "string" ? t : t.id;
|
||||
|
||||
const reportTeamPlayerIds = (t: Match["home"]): string[] =>
|
||||
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
|
||||
|
||||
function patchTournamentsData(
|
||||
data: unknown,
|
||||
matchId: string,
|
||||
patch: (match: Match) => Partial<Match>
|
||||
): unknown {
|
||||
if (!data) return data;
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((entry) => patchTournamentsData(entry, matchId, patch));
|
||||
}
|
||||
const tournament = data as { matches?: Match[] };
|
||||
if (!Array.isArray(tournament.matches)) return data;
|
||||
|
||||
let changed = false;
|
||||
const matches = tournament.matches.map((m) => {
|
||||
if (m.id !== matchId) return m;
|
||||
changed = true;
|
||||
return { ...m, ...patch(m) };
|
||||
});
|
||||
return changed ? { ...tournament, matches } : data;
|
||||
}
|
||||
|
||||
type MatchVariables = { data: { matchId: string } };
|
||||
type TournamentsSnapshot = [readonly unknown[], unknown][];
|
||||
|
||||
function useOptimisticMatchMutation<TData, TVariables extends MatchVariables>(
|
||||
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
|
||||
buildPatch: (match: Match, variables: TVariables) => Partial<Match>;
|
||||
}
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { buildPatch, ...mutationOptions } = options;
|
||||
|
||||
return useServerMutation<TData, TVariables>({
|
||||
...mutationOptions,
|
||||
onMutate: async (variables) => {
|
||||
const { matchId } = variables.data;
|
||||
await queryClient.cancelQueries(tournamentsRoot);
|
||||
|
||||
const previous = queryClient.getQueriesData(tournamentsRoot);
|
||||
queryClient.setQueriesData(tournamentsRoot, (data: unknown) =>
|
||||
patchTournamentsData(data, matchId, (match) => buildPatch(match, variables))
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (context && typeof context === "object" && "previous" in context) {
|
||||
const snapshot = (context as { previous: TournamentsSnapshot }).previous;
|
||||
for (const [key, data] of snapshot) {
|
||||
queryClient.setQueryData(key, data);
|
||||
}
|
||||
}
|
||||
mutationOptions.onError?.(error, variables, onMutateResult, context);
|
||||
},
|
||||
onSettled: (data, error, variables, onMutateResult, context) => {
|
||||
queryClient.invalidateQueries(tournamentsRoot);
|
||||
mutationOptions.onSettled?.(data, error, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type MatchMutationOptions = {
|
||||
onSuccess?: Parameters<typeof useServerMutation>[0]["onSuccess"];
|
||||
};
|
||||
|
||||
export const useReportMatchScore = (userId?: string, options?: MatchMutationOptions) =>
|
||||
useOptimisticMatchMutation({
|
||||
mutationFn: reportMatchScore,
|
||||
showSuccessToast: false,
|
||||
...options,
|
||||
buildPatch: (match, variables) => {
|
||||
const onHome = !!userId && reportTeamPlayerIds(match.home).includes(userId);
|
||||
const onAway = !!userId && reportTeamPlayerIds(match.away).includes(userId);
|
||||
const callerTeamId = onHome
|
||||
? reportTeamId(match.home)
|
||||
: onAway
|
||||
? reportTeamId(match.away)
|
||||
: undefined;
|
||||
return {
|
||||
reported_home_cups: variables.data.home_cups,
|
||||
reported_away_cups: variables.data.away_cups,
|
||||
reported_ot_count: variables.data.ot_count,
|
||||
reported_by_team: callerTeamId,
|
||||
reported_by_player: userId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const useConfirmMatchScore = (options?: MatchMutationOptions) =>
|
||||
useOptimisticMatchMutation({
|
||||
mutationFn: confirmMatchScore,
|
||||
showSuccessToast: false,
|
||||
...options,
|
||||
buildPatch: (match) => ({
|
||||
status: "ended",
|
||||
home_cups: match.reported_home_cups ?? match.home_cups,
|
||||
away_cups: match.reported_away_cups ?? match.away_cups,
|
||||
ot_count: match.reported_ot_count ?? match.ot_count,
|
||||
reported_home_cups: undefined,
|
||||
reported_away_cups: undefined,
|
||||
reported_ot_count: undefined,
|
||||
reported_by_team: undefined,
|
||||
reported_by_player: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
export const useClearMatchReport = (options?: MatchMutationOptions) =>
|
||||
useOptimisticMatchMutation({
|
||||
mutationFn: clearMatchReport,
|
||||
...options,
|
||||
buildPatch: () => ({
|
||||
reported_home_cups: undefined,
|
||||
reported_away_cups: undefined,
|
||||
reported_ot_count: undefined,
|
||||
reported_by_team: undefined,
|
||||
reported_by_player: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
+471
-64
@@ -212,7 +212,6 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
|
||||
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
||||
|
||||
for (const team of group.teams || []) {
|
||||
// group.teams can be either team objects or just team ID strings
|
||||
const teamId = typeof team === 'string' ? team : team.id;
|
||||
standings.set(teamId, {
|
||||
teamId,
|
||||
@@ -456,6 +455,344 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
|
||||
logger.info('Knockout bracket populated successfully', { tournamentId });
|
||||
}
|
||||
|
||||
const teamId = (t: Match["home"] | string | undefined): string | undefined =>
|
||||
!t ? undefined : typeof t === "string" ? t : t.id;
|
||||
|
||||
const winnerId = (m: Match): string | undefined =>
|
||||
m.home_cups > m.away_cups ? teamId(m.home) : teamId(m.away);
|
||||
const loserId = (m: Match): string | undefined =>
|
||||
m.home_cups > m.away_cups ? teamId(m.away) : teamId(m.home);
|
||||
|
||||
function assertValidScore(home_cups: number, away_cups: number, ot_count: number) {
|
||||
if (home_cups === away_cups) throw new Error("A match cannot end in a tie");
|
||||
if (ot_count > 0) return;
|
||||
if (home_cups !== 10 && away_cups !== 10)
|
||||
throw new Error("At least one team must have 10 cups");
|
||||
if (home_cups === 10 && away_cups === 10)
|
||||
throw new Error("Both teams cannot have 10 cups");
|
||||
}
|
||||
|
||||
const CLEARED_RESULT = {
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
reported_home_cups: null,
|
||||
reported_away_cups: null,
|
||||
reported_ot_count: null,
|
||||
reported_by_team: null,
|
||||
reported_by_player: null,
|
||||
} as const;
|
||||
|
||||
function generateRecordId(): string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let id = "";
|
||||
for (let i = 0; i < 15; i++)
|
||||
id += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
return id;
|
||||
}
|
||||
|
||||
type BracketOp =
|
||||
| { kind: "updateMatch"; id: string; data: Record<string, unknown> }
|
||||
| { kind: "createMatch"; data: Record<string, unknown> }
|
||||
| { kind: "deleteMatch"; id: string }
|
||||
| { kind: "updateTournamentMatches"; tournamentId: string; matchIds: string[] };
|
||||
|
||||
function isBatchApiUnavailable(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const e = error as { status?: unknown; url?: unknown };
|
||||
const url = typeof e.url === "string" ? e.url : "";
|
||||
if (!url.includes("/api/batch")) return false;
|
||||
return e.status === 403 || e.status === 404;
|
||||
}
|
||||
|
||||
async function commitBracketWrites(ops: BracketOp[]): Promise<void> {
|
||||
const batch = pbAdmin.createBatch();
|
||||
for (const op of ops) {
|
||||
switch (op.kind) {
|
||||
case "updateMatch":
|
||||
batch.collection("matches").update(op.id, op.data);
|
||||
break;
|
||||
case "createMatch":
|
||||
batch.collection("matches").create(op.data);
|
||||
break;
|
||||
case "deleteMatch":
|
||||
batch.collection("matches").delete(op.id);
|
||||
break;
|
||||
case "updateTournamentMatches":
|
||||
batch
|
||||
.collection("tournaments")
|
||||
.update(op.tournamentId, { matches: op.matchIds });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await batch.send();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isBatchApiUnavailable(error)) throw error;
|
||||
logger.warn(
|
||||
"PocketBase batch API is disabled; finalizing match with sequential writes (reduced atomicity). Enable Settings > Application > batch API to restore transactional bracket updates.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const op of ops) {
|
||||
switch (op.kind) {
|
||||
case "updateMatch":
|
||||
await pbAdmin.updateMatch(op.id, op.data as Partial<MatchInput>);
|
||||
break;
|
||||
case "createMatch":
|
||||
await pbAdmin.createMatch(op.data as unknown as MatchInput);
|
||||
break;
|
||||
case "deleteMatch":
|
||||
await pbAdmin.deleteMatch(op.id);
|
||||
break;
|
||||
case "updateTournamentMatches":
|
||||
await pbAdmin.updateTournamentMatches(op.tournamentId, op.matchIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rederiveBracket(
|
||||
tournamentId: string,
|
||||
sourceId: string,
|
||||
score: { home_cups: number; away_cups: number; ot_count: number }
|
||||
): Promise<{ source: Match; downstreamReset: boolean }> {
|
||||
const all = await pbAdmin.getMatchesByTournament(tournamentId);
|
||||
const knockout = all.filter((m) => m.lid >= 0);
|
||||
const byLid = new Map<number, Match>();
|
||||
for (const m of knockout) byLid.set(m.lid, m);
|
||||
|
||||
const original = all.find((m) => m.id === sourceId);
|
||||
if (!original) throw new Error("Match not found");
|
||||
|
||||
const ops: BracketOp[] = [];
|
||||
let warn = false;
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
ops.push({
|
||||
kind: "updateMatch",
|
||||
id: sourceId,
|
||||
data: {
|
||||
end_time: nowIso,
|
||||
status: "ended",
|
||||
home_cups: score.home_cups,
|
||||
away_cups: score.away_cups,
|
||||
ot_count: score.ot_count,
|
||||
reported_home_cups: null,
|
||||
reported_away_cups: null,
|
||||
reported_ot_count: null,
|
||||
reported_by_team: null,
|
||||
reported_by_player: null,
|
||||
},
|
||||
});
|
||||
const source: Match = {
|
||||
...original,
|
||||
status: "ended",
|
||||
home_cups: score.home_cups,
|
||||
away_cups: score.away_cups,
|
||||
ot_count: score.ot_count,
|
||||
end_time: nowIso,
|
||||
reported_home_cups: undefined,
|
||||
reported_away_cups: undefined,
|
||||
reported_ot_count: undefined,
|
||||
reported_by_team: undefined,
|
||||
reported_by_player: undefined,
|
||||
};
|
||||
byLid.set(source.lid, source);
|
||||
|
||||
const resolveFeeder = (lid: number, fromLoser: boolean): string | undefined => {
|
||||
const f = byLid.get(lid);
|
||||
if (!f || f.bye || f.status !== "ended") return undefined;
|
||||
return fromLoser ? loserId(f) : winnerId(f);
|
||||
};
|
||||
|
||||
const hasLosers = knockout.some((m) => m.is_losers_bracket);
|
||||
const resetMatch = knockout.find((m) => m.reset);
|
||||
const grandFinal = hasLosers
|
||||
? knockout
|
||||
.filter((m) => !m.reset && !m.bye)
|
||||
.reduce<Match | undefined>(
|
||||
(hi, cur) => (!hi || cur.lid > hi.lid ? cur : hi),
|
||||
undefined
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const ordered = knockout
|
||||
.filter((m) => !m.bye && !m.reset)
|
||||
.sort((a, b) => a.lid - b.lid);
|
||||
|
||||
for (const m of ordered) {
|
||||
if (m.id === sourceId) continue;
|
||||
|
||||
const homeFed = m.home_from_lid >= 0;
|
||||
const awayFed = m.away_from_lid >= 0;
|
||||
if (!homeFed && !awayFed) continue;
|
||||
|
||||
const curHome = teamId(m.home);
|
||||
const curAway = teamId(m.away);
|
||||
const expHome = homeFed ? resolveFeeder(m.home_from_lid, m.home_from_loser) : curHome;
|
||||
const expAway = awayFed ? resolveFeeder(m.away_from_lid, m.away_from_loser) : curAway;
|
||||
const expStatus = expHome && expAway ? "ready" : "tbd";
|
||||
|
||||
const played = m.status === "started" || m.status === "ended";
|
||||
const changed =
|
||||
(homeFed && expHome !== curHome) || (awayFed && expAway !== curAway);
|
||||
|
||||
if (played && changed) {
|
||||
ops.push({
|
||||
kind: "updateMatch",
|
||||
id: m.id,
|
||||
data: {
|
||||
home: expHome ?? null,
|
||||
away: expAway ?? null,
|
||||
status: expStatus,
|
||||
...CLEARED_RESULT,
|
||||
},
|
||||
});
|
||||
byLid.set(m.lid, { ...m, status: expStatus });
|
||||
warn = true;
|
||||
} else if (!played && (changed || m.status !== expStatus)) {
|
||||
ops.push({
|
||||
kind: "updateMatch",
|
||||
id: m.id,
|
||||
data: {
|
||||
home: expHome ?? null,
|
||||
away: expAway ?? null,
|
||||
status: expStatus,
|
||||
},
|
||||
});
|
||||
byLid.set(m.lid, { ...m, status: expStatus });
|
||||
}
|
||||
}
|
||||
|
||||
if (grandFinal) {
|
||||
const gf = byLid.get(grandFinal.lid)!;
|
||||
const gfEnded = gf.status === "ended";
|
||||
const resetNeeded = gfEnded && winnerId(gf) === teamId(gf.away);
|
||||
|
||||
if (resetNeeded) {
|
||||
const expHome = winnerId(gf);
|
||||
const expAway = loserId(gf);
|
||||
|
||||
if (resetMatch && resetMatch.id !== sourceId) {
|
||||
const played =
|
||||
resetMatch.status === "started" || resetMatch.status === "ended";
|
||||
const changed =
|
||||
teamId(resetMatch.home) !== expHome || teamId(resetMatch.away) !== expAway;
|
||||
if (played && changed) {
|
||||
ops.push({
|
||||
kind: "updateMatch",
|
||||
id: resetMatch.id,
|
||||
data: {
|
||||
home: expHome ?? null,
|
||||
away: expAway ?? null,
|
||||
status: "ready",
|
||||
...CLEARED_RESULT,
|
||||
},
|
||||
});
|
||||
warn = true;
|
||||
} else if (!played && (changed || resetMatch.status !== "ready")) {
|
||||
ops.push({
|
||||
kind: "updateMatch",
|
||||
id: resetMatch.id,
|
||||
data: {
|
||||
home: expHome ?? null,
|
||||
away: expAway ?? null,
|
||||
status: "ready",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (!resetMatch) {
|
||||
const newId = generateRecordId();
|
||||
ops.push({
|
||||
kind: "createMatch",
|
||||
data: {
|
||||
id: newId,
|
||||
lid: gf.lid + 1,
|
||||
order: gf.order + 1,
|
||||
round: gf.round + 1,
|
||||
reset: true,
|
||||
bye: false,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
home_from_lid: gf.lid,
|
||||
away_from_lid: gf.lid,
|
||||
home_from_loser: false,
|
||||
away_from_loser: true,
|
||||
is_losers_bracket: false,
|
||||
status: "ready",
|
||||
tournament: tournamentId,
|
||||
home: expHome ?? undefined,
|
||||
away: expAway ?? undefined,
|
||||
},
|
||||
});
|
||||
ops.push({
|
||||
kind: "updateTournamentMatches",
|
||||
tournamentId,
|
||||
matchIds: [...all.map((m) => m.id), newId],
|
||||
});
|
||||
}
|
||||
} else if (resetMatch && resetMatch.id !== sourceId) {
|
||||
if (resetMatch.status === "started" || resetMatch.status === "ended") {
|
||||
warn = true;
|
||||
}
|
||||
ops.push({ kind: "deleteMatch", id: resetMatch.id });
|
||||
}
|
||||
}
|
||||
|
||||
await commitBracketWrites(ops);
|
||||
|
||||
return { source, downstreamReset: warn };
|
||||
}
|
||||
|
||||
async function finalizeMatch(
|
||||
matchId: string,
|
||||
{ home_cups, away_cups, ot_count }: { home_cups: number; away_cups: number; ot_count: number }
|
||||
): Promise<{ match: Match; downstreamReset: boolean; groupEditAfterKnockout: boolean }> {
|
||||
assertValidScore(home_cups, away_cups, ot_count);
|
||||
|
||||
const existing = await pbAdmin.getMatch(matchId);
|
||||
if (!existing) throw new Error("Match not found");
|
||||
const tournamentId = existing.tournament.id;
|
||||
|
||||
if (existing.lid === -1) {
|
||||
const source = await pbAdmin.updateMatch(matchId, {
|
||||
end_time: new Date().toISOString(),
|
||||
status: "ended",
|
||||
home_cups,
|
||||
away_cups,
|
||||
ot_count,
|
||||
reported_home_cups: null,
|
||||
reported_away_cups: null,
|
||||
reported_ot_count: null,
|
||||
reported_by_team: null,
|
||||
reported_by_player: null,
|
||||
});
|
||||
|
||||
const all = await pbAdmin.getMatchesByTournament(tournamentId);
|
||||
const groupEditAfterKnockout = all.some(
|
||||
(m) => m.round >= 0 && (teamId(m.home) || teamId(m.away))
|
||||
);
|
||||
|
||||
emitServerEvent({ type: "match", matchId: source.id, tournamentId });
|
||||
return { match: source, downstreamReset: false, groupEditAfterKnockout };
|
||||
}
|
||||
|
||||
const { source, downstreamReset } = await rederiveBracket(tournamentId, matchId, {
|
||||
home_cups,
|
||||
away_cups,
|
||||
ot_count,
|
||||
});
|
||||
|
||||
emitServerEvent({ type: "match", matchId: source.id, tournamentId });
|
||||
return { match: source, downstreamReset, groupEditAfterKnockout: false };
|
||||
}
|
||||
|
||||
const endMatchSchema = z.object({
|
||||
matchId: z.string(),
|
||||
home_cups: z.number(),
|
||||
@@ -469,83 +806,153 @@ export const endMatch = createServerFn()
|
||||
toServerResult(async () => {
|
||||
logger.info("Ending match", matchId);
|
||||
|
||||
let match = await pbAdmin.getMatch(matchId);
|
||||
const match = await pbAdmin.getMatch(matchId);
|
||||
if (!match) {
|
||||
throw new Error("Match not found");
|
||||
}
|
||||
|
||||
match = await pbAdmin.updateMatch(matchId, {
|
||||
end_time: new Date().toISOString(),
|
||||
status: "ended",
|
||||
home_cups,
|
||||
away_cups,
|
||||
ot_count,
|
||||
});
|
||||
return finalizeMatch(matchId, { home_cups, away_cups, ot_count });
|
||||
})
|
||||
);
|
||||
|
||||
if (match.lid === -1) {
|
||||
emitServerEvent({
|
||||
type: "match",
|
||||
matchId: match.id,
|
||||
tournamentId: match.tournament.id
|
||||
});
|
||||
return match;
|
||||
const teamPlayerIds = (team: Match["home"]): string[] =>
|
||||
team && typeof team !== "string" ? (team.players ?? []).map((p) => p.id) : [];
|
||||
|
||||
const reportScoreSchema = z.object({
|
||||
matchId: z.string(),
|
||||
home_cups: z.number(),
|
||||
away_cups: z.number(),
|
||||
ot_count: z.number(),
|
||||
});
|
||||
|
||||
export const reportMatchScore = createServerFn()
|
||||
.validator(reportScoreSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
if (!player?.id) throw new Error("Player not found");
|
||||
|
||||
const match = await pbAdmin.getMatch(matchId);
|
||||
if (!match) throw new Error("Match not found");
|
||||
if (match.status !== "started")
|
||||
throw new Error("Scores can only be reported once the match has started");
|
||||
|
||||
const onHome = teamPlayerIds(match.home).includes(player.id);
|
||||
const onAway = teamPlayerIds(match.away).includes(player.id);
|
||||
if (!onHome && !onAway)
|
||||
throw new Error("You are not a player in this match");
|
||||
if (onHome && onAway)
|
||||
throw new Error(
|
||||
"You are on both teams in this match and cannot report or confirm its score"
|
||||
);
|
||||
|
||||
assertValidScore(home_cups, away_cups, ot_count);
|
||||
|
||||
const callerTeamId = onHome ? teamId(match.home) : teamId(match.away);
|
||||
|
||||
const hasPending =
|
||||
match.reported_by_team != null &&
|
||||
match.reported_home_cups != null &&
|
||||
match.reported_away_cups != null;
|
||||
const fromOppositeTeam =
|
||||
hasPending && match.reported_by_team !== callerTeamId;
|
||||
const identical =
|
||||
hasPending &&
|
||||
match.reported_home_cups === home_cups &&
|
||||
match.reported_away_cups === away_cups &&
|
||||
(match.reported_ot_count ?? 0) === ot_count;
|
||||
|
||||
if (fromOppositeTeam && identical) {
|
||||
const result = await finalizeMatch(matchId, { home_cups, away_cups, ot_count });
|
||||
return {
|
||||
success: true as const,
|
||||
finalized: true as const,
|
||||
downstreamReset: result.downstreamReset,
|
||||
groupEditAfterKnockout: result.groupEditAfterKnockout,
|
||||
};
|
||||
}
|
||||
|
||||
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
||||
const matchLoser = home_cups < away_cups ? match.home : match.away;
|
||||
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
|
||||
|
||||
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
|
||||
|
||||
if (winner && winner.reset) {
|
||||
const awayTeamWon = match.away === matchWinner;
|
||||
|
||||
if (!awayTeamWon) {
|
||||
logger.info("Deleting reset match", {
|
||||
resetMatchId: winner.id,
|
||||
currentMatchId: match.id,
|
||||
reason: "not necessary",
|
||||
await pbAdmin.updateMatch(matchId, {
|
||||
reported_home_cups: home_cups,
|
||||
reported_away_cups: away_cups,
|
||||
reported_ot_count: ot_count,
|
||||
reported_by_team: callerTeamId,
|
||||
reported_by_player: player.id,
|
||||
});
|
||||
|
||||
await pbAdmin.deleteMatch(winner.id);
|
||||
emitServerEvent({
|
||||
type: "match",
|
||||
matchId: match.id,
|
||||
tournamentId: match.tournament.id
|
||||
});
|
||||
return match;
|
||||
}
|
||||
}
|
||||
emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
|
||||
return { success: true as const, finalized: false as const };
|
||||
})
|
||||
);
|
||||
|
||||
if (winner) {
|
||||
await pbAdmin.updateMatch(winner.id, {
|
||||
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
|
||||
status:
|
||||
(winner.home_from_lid === match.lid && winner.away) ||
|
||||
(winner.away_from_lid === match.lid && winner.home)
|
||||
? "ready"
|
||||
: "tbd",
|
||||
});
|
||||
}
|
||||
const matchIdSchema = z.object({ matchId: z.string() });
|
||||
|
||||
if (loser) {
|
||||
await pbAdmin.updateMatch(loser.id, {
|
||||
[loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id,
|
||||
status:
|
||||
(loser.home_from_lid === match.lid && loser.away) ||
|
||||
(loser.away_from_lid === match.lid && loser.home)
|
||||
? "ready"
|
||||
: "tbd",
|
||||
});
|
||||
}
|
||||
export const confirmMatchScore = createServerFn()
|
||||
.validator(matchIdSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data: { matchId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
if (!player?.id) throw new Error("Player not found");
|
||||
|
||||
emitServerEvent({
|
||||
type: "match",
|
||||
matchId: match.id,
|
||||
tournamentId: match.tournament.id
|
||||
const match = await pbAdmin.getMatch(matchId);
|
||||
if (!match) throw new Error("Match not found");
|
||||
if (match.status !== "started")
|
||||
throw new Error("Match is not in progress");
|
||||
if (!match.reported_by_team)
|
||||
throw new Error("No score has been reported yet");
|
||||
if (match.reported_home_cups == null || match.reported_away_cups == null)
|
||||
throw new Error("The reported score is incomplete");
|
||||
|
||||
const onHome = teamPlayerIds(match.home).includes(player.id);
|
||||
const onAway = teamPlayerIds(match.away).includes(player.id);
|
||||
if (onHome && onAway)
|
||||
throw new Error(
|
||||
"You are on both teams in this match and cannot report or confirm its score"
|
||||
);
|
||||
|
||||
const homeId = teamId(match.home);
|
||||
const reportedByHome = match.reported_by_team === homeId;
|
||||
const opposingTeam = reportedByHome ? match.away : match.home;
|
||||
|
||||
if (!teamPlayerIds(opposingTeam).includes(player.id))
|
||||
throw new Error("Only a player on the other team can confirm this score");
|
||||
|
||||
return finalizeMatch(matchId, {
|
||||
home_cups: match.reported_home_cups,
|
||||
away_cups: match.reported_away_cups,
|
||||
ot_count: match.reported_ot_count ?? 0,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export const clearMatchReport = createServerFn()
|
||||
.validator(matchIdSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data: { matchId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
if (!player?.id) throw new Error("Player not found");
|
||||
|
||||
const match = await pbAdmin.getMatch(matchId);
|
||||
if (!match) throw new Error("Match not found");
|
||||
|
||||
const onHome = teamPlayerIds(match.home).includes(player.id);
|
||||
const onAway = teamPlayerIds(match.away).includes(player.id);
|
||||
if (!onHome && !onAway)
|
||||
throw new Error("You are not a player in this match");
|
||||
|
||||
await pbAdmin.updateMatch(matchId, {
|
||||
reported_home_cups: null,
|
||||
reported_away_cups: null,
|
||||
reported_ot_count: null,
|
||||
reported_by_team: null,
|
||||
reported_by_player: null,
|
||||
});
|
||||
|
||||
return match;
|
||||
emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface Match {
|
||||
away_seed?: number;
|
||||
match_type?: MatchType;
|
||||
group?: string;
|
||||
reported_home_cups?: number;
|
||||
reported_away_cups?: number;
|
||||
reported_ot_count?: number;
|
||||
reported_by_team?: string;
|
||||
reported_by_player?: string;
|
||||
}
|
||||
|
||||
export const matchInputSchema = z.object({
|
||||
@@ -52,12 +57,17 @@ export const matchInputSchema = z.object({
|
||||
is_losers_bracket: z.boolean().optional().default(false),
|
||||
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
|
||||
tournament: z.string().min(1),
|
||||
home: z.string().min(1).optional(),
|
||||
away: z.string().min(1).optional(),
|
||||
home: z.string().min(1).nullable().optional(),
|
||||
away: z.string().min(1).nullable().optional(),
|
||||
home_seed: z.number().int().min(1).optional(),
|
||||
away_seed: z.number().int().min(1).optional(),
|
||||
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
|
||||
group: z.string().optional(),
|
||||
reported_home_cups: z.number().int().min(0).nullable().optional(),
|
||||
reported_away_cups: z.number().int().min(0).nullable().optional(),
|
||||
reported_ot_count: z.number().int().min(0).nullable().optional(),
|
||||
reported_by_team: z.string().nullable().optional(),
|
||||
reported_by_player: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type MatchInput = z.infer<typeof matchInputSchema>;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
|
||||
import { useWebPush } from "@/hooks/use-web-push";
|
||||
import toast from "@/lib/sonner";
|
||||
|
||||
export function NotificationsSection() {
|
||||
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
|
||||
useWebPush();
|
||||
|
||||
if (!supported || !configured) {
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Title order={3}>Notifications</Title>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{supported
|
||||
? "Notifications aren't available right now."
|
||||
: "This device doesn't support push notifications. On iOS, install the app to your Home Screen first."}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
if (checked) {
|
||||
const ok = await enable();
|
||||
if (ok) {
|
||||
toast.success("Notifications enabled on this device");
|
||||
} else if (permission === "denied") {
|
||||
toast.error(
|
||||
"Notifications are blocked. Enable them in your browser settings."
|
||||
);
|
||||
} else {
|
||||
toast.error("Couldn't enable notifications");
|
||||
}
|
||||
} else {
|
||||
const ok = await disable();
|
||||
if (ok) toast.success("Notifications disabled on this device");
|
||||
else toast.error("Couldn't disable notifications");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const ok = await sendTest();
|
||||
if (ok) toast.success("Test notification sent");
|
||||
else toast.error("Couldn't send test notification");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Title order={3}>Notifications</Title>
|
||||
<Stack mt="xs" gap="sm">
|
||||
<Switch
|
||||
checked={subscribed}
|
||||
onChange={(e) => handleToggle(e.currentTarget.checked)}
|
||||
disabled={busy}
|
||||
label="Enable notifications on this device"
|
||||
/>
|
||||
{subscribed && (
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={handleTest}
|
||||
loading={busy}
|
||||
>
|
||||
Send test notification
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationsSection;
|
||||
@@ -15,12 +15,14 @@ import AnimatedScore from "@/features/matches/components/animated-score";
|
||||
interface GroupMatchCardProps {
|
||||
match: Match;
|
||||
showControls?: boolean;
|
||||
nextUpMatchId?: string;
|
||||
}
|
||||
|
||||
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls }) => {
|
||||
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, nextUpMatchId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const editSheet = useSheet();
|
||||
|
||||
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
|
||||
const isReady = match.status === "ready";
|
||||
const isStarted = match.status === "started";
|
||||
const isEnded = match.status === "ended";
|
||||
@@ -73,7 +75,17 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex direction="row" align="stretch">
|
||||
<Flex
|
||||
direction="row"
|
||||
align="stretch"
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
boxShadow: isNextUp
|
||||
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
|
||||
: undefined,
|
||||
transition: "box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
}}
|
||||
>
|
||||
<Indicator
|
||||
inline
|
||||
processing={isStarted}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutatio
|
||||
import { populateKnockoutBracket } from "@/features/matches/server";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentKeys } from "../queries";
|
||||
import { computeGroupStageOrder } from "../utils/match-queue";
|
||||
|
||||
interface GroupStageViewProps {
|
||||
groups: Group[];
|
||||
@@ -18,6 +19,7 @@ interface GroupStageViewProps {
|
||||
hasKnockoutBracket?: boolean;
|
||||
isRegional?: boolean;
|
||||
groupConfig?: GroupConfig;
|
||||
nextUpMatchId?: string;
|
||||
}
|
||||
|
||||
interface TeamStanding {
|
||||
@@ -39,6 +41,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
hasKnockoutBracket,
|
||||
isRegional,
|
||||
groupConfig,
|
||||
nextUpMatchId,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||
@@ -64,35 +67,22 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
populateKnockoutMutation.mutate({ data: { tournamentId } });
|
||||
};
|
||||
|
||||
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
|
||||
if (matches.length <= 1) return matches;
|
||||
const globalOrderRank = useMemo(() => {
|
||||
const groupMatches = matches.filter((match) => match.round === -1 && match.group);
|
||||
const order = computeGroupStageOrder(
|
||||
groupMatches.map((m) => ({
|
||||
groupId: m.group ?? "",
|
||||
teamA: m.home?.id ?? "",
|
||||
teamB: m.away?.id ?? "",
|
||||
})),
|
||||
);
|
||||
|
||||
const ordered: Match[] = [];
|
||||
const remaining = [...matches];
|
||||
|
||||
ordered.push(remaining.shift()!);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastMatch = ordered[ordered.length - 1];
|
||||
const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
|
||||
|
||||
let bestMatchIndex = remaining.findIndex((match) => {
|
||||
const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
|
||||
for (const teamId of currentTeams) {
|
||||
if (lastTeams.has(teamId)) return false;
|
||||
}
|
||||
return true;
|
||||
const rank = new Map<string, number>();
|
||||
order.forEach((matchIdx, position) => {
|
||||
rank.set(groupMatches[matchIdx].id, position);
|
||||
});
|
||||
|
||||
if (bestMatchIndex === -1) {
|
||||
bestMatchIndex = 0;
|
||||
}
|
||||
|
||||
ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
};
|
||||
return rank;
|
||||
}, [matches]);
|
||||
|
||||
const matchesByGroup = useMemo(() => {
|
||||
const map = new Map<string, Match[]>();
|
||||
@@ -107,11 +97,14 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
});
|
||||
|
||||
map.forEach((groupMatches, groupId) => {
|
||||
map.set(groupId, orderMatchesWithSpacing(groupMatches));
|
||||
const sorted = [...groupMatches].sort(
|
||||
(a, b) => (globalOrderRank.get(a.id) ?? 0) - (globalOrderRank.get(b.id) ?? 0),
|
||||
);
|
||||
map.set(groupId, sorted);
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [matches]);
|
||||
}, [matches, globalOrderRank]);
|
||||
|
||||
const sortedGroups = useMemo(() => {
|
||||
return [...groups].sort((a, b) => a.order - b.order);
|
||||
@@ -461,6 +454,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
key={match.id}
|
||||
match={match}
|
||||
showControls={showControls}
|
||||
nextUpMatchId={nextUpMatchId}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
||||
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
||||
import { Tournament } from "../../types";
|
||||
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
@@ -11,7 +11,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack px="sm" align="center" gap={0}>
|
||||
<Group px="md" gap="md" wrap="nowrap" align="center">
|
||||
<GlitchAvatar
|
||||
name={tournament.name}
|
||||
src={
|
||||
@@ -25,27 +25,27 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
: undefined
|
||||
}
|
||||
radius="md"
|
||||
size={250}
|
||||
size={64}
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
<TrophyIcon size={24} />
|
||||
</GlitchAvatar>
|
||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
{tournament.location && (
|
||||
<Group gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<MapPinIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{tournament.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<CalendarIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{tournamentStart.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
@@ -58,8 +58,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,50 +1,83 @@
|
||||
import { useMemo } from "react";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Box, Divider, Stack, Text, Card, Center, Group, Indicator } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import carouselClasses from "./carousel.module.css";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||
import { predictionQueries } from "@/features/predictions/queries";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import Header from "./header";
|
||||
import { Podium } from "../podium";
|
||||
import SectionHeading from "./sections/section-heading";
|
||||
import MatchCarousel from "./sections/match-carousel";
|
||||
import ScoreReportCta from "./sections/score-report-cta";
|
||||
import RecentResults from "./sections/recent-results";
|
||||
import NavGrid from "./sections/nav-grid";
|
||||
|
||||
const teamPlayerIds = (t: Match["home"]): string[] =>
|
||||
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
|
||||
|
||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const { roles } = useAuth();
|
||||
const { user, roles } = useAuth();
|
||||
|
||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||
|
||||
const startedMatches = useMemo(() =>
|
||||
tournament.matches?.filter(match => match.status === "started") || [],
|
||||
[tournament.matches]
|
||||
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
|
||||
|
||||
const liveMatches = useMemo(
|
||||
() => matches.filter((m) => m.status === "started"),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const upcomingMatches = useMemo(
|
||||
() => matches.filter((m) => m.status === "ready" && m.home && m.away),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const endedMatches = useMemo(
|
||||
() =>
|
||||
matches
|
||||
.filter((m) => m.status === "ended")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.end_time).getTime() - new Date(a.end_time).getTime()
|
||||
),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const myLiveMatches = useMemo(
|
||||
() =>
|
||||
liveMatches.filter(
|
||||
(m) =>
|
||||
!!user?.id &&
|
||||
(teamPlayerIds(m.home).includes(user.id) ||
|
||||
teamPlayerIds(m.away).includes(user.id))
|
||||
),
|
||||
[liveMatches, user?.id]
|
||||
);
|
||||
|
||||
const isTournamentOver = useMemo(() => {
|
||||
const matches = tournament.matches || [];
|
||||
if (matches.length === 0) return false;
|
||||
|
||||
const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
|
||||
const nonByeMatches = matches.filter(
|
||||
(match) => !(match.status === "tbd" && match.bye === true)
|
||||
);
|
||||
if (nonByeMatches.length === 0) return false;
|
||||
|
||||
const finalsMatch = nonByeMatches.reduce((highest, current) =>
|
||||
(!highest || current.lid > highest.lid) ? current : highest
|
||||
!highest || current.lid > highest.lid ? current : highest
|
||||
);
|
||||
|
||||
return finalsMatch?.status === 'ended';
|
||||
}, [tournament.matches]);
|
||||
return finalsMatch?.status === "ended";
|
||||
}, [matches]);
|
||||
|
||||
const hasGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
const hasGroupStage = useMemo(
|
||||
() => matches.some((match) => match.round === -1),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const isPredictable = useMemo(
|
||||
() => isTournamentPredictable(tournament),
|
||||
@@ -52,8 +85,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
);
|
||||
|
||||
const predictionsLocked = useMemo(
|
||||
() => isPredictionLocked(tournament.matches || []),
|
||||
[tournament.matches]
|
||||
() => isPredictionLocked(matches),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const { data: myPrediction } = useServerQuery({
|
||||
@@ -62,48 +95,42 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
});
|
||||
const hasSubmitted = !!myPrediction?.prediction;
|
||||
|
||||
const hasNoMatchContent =
|
||||
!isTournamentOver &&
|
||||
liveMatches.length === 0 &&
|
||||
upcomingMatches.length === 0 &&
|
||||
endedMatches.length === 0;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Header tournament={tournament} />
|
||||
|
||||
{startedMatches.length > 0 ? (
|
||||
<Box>
|
||||
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap">
|
||||
<Indicator
|
||||
size={8}
|
||||
color="red"
|
||||
processing
|
||||
position="middle-start"
|
||||
offset={0}
|
||||
/>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em">
|
||||
Live Matches{startedMatches.length > 1 && ` · ${startedMatches.length}`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Carousel
|
||||
slideSize="90%"
|
||||
slideGap="xs"
|
||||
withControls={false}
|
||||
withIndicators={startedMatches.length > 1}
|
||||
classNames={{
|
||||
indicators: carouselClasses.indicators,
|
||||
indicator: carouselClasses.indicator,
|
||||
}}
|
||||
>
|
||||
{startedMatches.map((match, index) => (
|
||||
<Carousel.Slide key={match.id}>
|
||||
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
|
||||
<MatchCard match={match} />
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
))}
|
||||
</Carousel>
|
||||
</Box>
|
||||
) : isTournamentOver ? (
|
||||
<ScoreReportCta matches={myLiveMatches} />
|
||||
|
||||
{isTournamentOver && (
|
||||
<Box px="lg" w="100%">
|
||||
<Podium tournament={tournament} />
|
||||
</Box>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{liveMatches.length > 0 && (
|
||||
<Box>
|
||||
<SectionHeading label="Live Matches" live count={liveMatches.length} />
|
||||
<MatchCarousel matches={liveMatches} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{upcomingMatches.length > 0 && (
|
||||
<Box>
|
||||
<SectionHeading label="Upcoming" count={upcomingMatches.length} />
|
||||
{/* SIDE BETS SLOT — see docs/side-bets.md */}
|
||||
<MatchCarousel matches={upcomingMatches} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<RecentResults matches={endedMatches} tournamentId={tournament.id} />
|
||||
|
||||
{hasNoMatchContent && (
|
||||
<Card withBorder radius="lg" p="xl" mx="md">
|
||||
<Center>
|
||||
<Stack align="center" gap="md">
|
||||
@@ -117,42 +144,15 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Divider />
|
||||
{isAdmin && (
|
||||
<ListLink
|
||||
label={`Manage ${tournament.name}`}
|
||||
to={`/admin/tournaments/${tournament.id}`}
|
||||
Icon={UsersIcon}
|
||||
<Divider mb="sm" />
|
||||
<NavGrid
|
||||
tournament={tournament}
|
||||
isAdmin={isAdmin}
|
||||
hasGroupStage={hasGroupStage}
|
||||
isPredictable={isPredictable}
|
||||
predictionsLocked={predictionsLocked}
|
||||
hasSubmitted={hasSubmitted}
|
||||
/>
|
||||
)}
|
||||
{hasGroupStage && (
|
||||
<ListLink
|
||||
label={`View Groups`}
|
||||
to={`/tournaments/${tournament.id}/groups`}
|
||||
Icon={ListDashes}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
Icon={TreeStructureIcon}
|
||||
/>
|
||||
{isPredictable && !predictionsLocked && (
|
||||
<ListLink
|
||||
label={hasSubmitted ? `Edit Your Prediction` : `Make Your Prediction`}
|
||||
to={`/tournaments/${tournament.id}/predictions/make`}
|
||||
Icon={WizardOrbIcon}
|
||||
/>
|
||||
)}
|
||||
{isPredictable && (predictionsLocked || hasSubmitted) && (
|
||||
<ListLink
|
||||
label={`View Predictions`}
|
||||
to={`/tournaments/${tournament.id}/predictions`}
|
||||
Icon={WizardOrbIcon}
|
||||
/>
|
||||
)}
|
||||
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
|
||||
<RulesListButton tournamentId={tournament.id} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Box } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import carouselClasses from "../carousel.module.css";
|
||||
|
||||
interface MatchCarouselProps {
|
||||
matches: Match[];
|
||||
}
|
||||
|
||||
const MatchCarousel = ({ matches }: MatchCarouselProps) => {
|
||||
if (matches.length === 1) {
|
||||
return (
|
||||
<Box px="md">
|
||||
<MatchCard match={matches[0]} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
slideSize="90%"
|
||||
slideGap="xs"
|
||||
withControls={false}
|
||||
withIndicators={matches.length > 1}
|
||||
classNames={{
|
||||
indicators: carouselClasses.indicators,
|
||||
indicator: carouselClasses.indicator,
|
||||
}}
|
||||
>
|
||||
{matches.map((match, index) => (
|
||||
<Carousel.Slide key={match.id}>
|
||||
<Box
|
||||
pl={index === 0 ? "md" : undefined}
|
||||
pr={index === matches.length - 1 ? "md" : undefined}
|
||||
>
|
||||
<MatchCard match={match} />
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
))}
|
||||
</Carousel>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchCarousel;
|
||||
@@ -0,0 +1,38 @@
|
||||
.tile {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 8px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
background-color: var(--mantine-color-body);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tile:hover:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.tile:active:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.tile {
|
||||
transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.tile:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Icon } from "@phosphor-icons/react";
|
||||
import {
|
||||
ListDashes,
|
||||
ListIcon,
|
||||
TreeStructureIcon,
|
||||
UsersIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import { Tournament } from "../../../types";
|
||||
import classes from "./nav-grid.module.css";
|
||||
|
||||
const RulesContent = lazy(
|
||||
() => import("../../upcoming-tournament/rules-content")
|
||||
);
|
||||
|
||||
interface NavTileProps {
|
||||
label: string;
|
||||
Icon: Icon;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** A single square-ish nav tile with icon-over-label and press feedback. */
|
||||
const NavTile = ({ label, Icon, onClick }: NavTileProps) => (
|
||||
<UnstyledButton className={classes.tile} onClick={onClick}>
|
||||
<span className={classes.icon}>
|
||||
<Icon size={22} weight="bold" />
|
||||
</span>
|
||||
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
|
||||
{label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
interface NavGridProps {
|
||||
tournament: Tournament;
|
||||
isAdmin: boolean;
|
||||
hasGroupStage: boolean;
|
||||
isPredictable: boolean;
|
||||
predictionsLocked: boolean;
|
||||
hasSubmitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidates the old stack of full-width ListLinks into a compact icon grid
|
||||
* so it no longer dominates the page. Every original destination stays
|
||||
* reachable with identical conditional visibility. Teams and Rules open sheets;
|
||||
* everything else navigates.
|
||||
*/
|
||||
const NavGrid = ({
|
||||
tournament,
|
||||
isAdmin,
|
||||
hasGroupStage,
|
||||
isPredictable,
|
||||
predictionsLocked,
|
||||
hasSubmitted,
|
||||
}: NavGridProps) => {
|
||||
const navigate = useNavigate();
|
||||
const teamsSheet = useSheet();
|
||||
const rulesSheet = useSheet();
|
||||
|
||||
return (
|
||||
<Box px="md">
|
||||
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||
{isAdmin && (
|
||||
<NavTile
|
||||
label="Manage"
|
||||
Icon={UsersIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/admin/tournaments/${tournament.id}` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasGroupStage && (
|
||||
<NavTile
|
||||
label="Groups"
|
||||
Icon={ListDashes}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/groups` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NavTile
|
||||
label="Bracket"
|
||||
Icon={TreeStructureIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/bracket` })
|
||||
}
|
||||
/>
|
||||
{isPredictable && !predictionsLocked && (
|
||||
<NavTile
|
||||
label={hasSubmitted ? "Edit Prediction" : "Make Prediction"}
|
||||
Icon={WizardOrbIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/predictions/make` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isPredictable && (predictionsLocked || hasSubmitted) && (
|
||||
<NavTile
|
||||
label="Predictions"
|
||||
Icon={WizardOrbIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/predictions` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NavTile
|
||||
label={`Teams${
|
||||
tournament.teams ? ` (${tournament.teams.length})` : ""
|
||||
}`}
|
||||
Icon={UsersIcon}
|
||||
onClick={teamsSheet.open}
|
||||
/>
|
||||
<NavTile label="Rules" Icon={ListIcon} onClick={rulesSheet.open} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Sheet title="Enrolled Teams" {...teamsSheet.props}>
|
||||
<TeamList
|
||||
teams={tournament.teams || []}
|
||||
isRegional={tournament.regional}
|
||||
/>
|
||||
</Sheet>
|
||||
|
||||
<Sheet title="Tournament Rules" {...rulesSheet.props}>
|
||||
<Stack gap="xs">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="100%"
|
||||
style={{ minHeight: "25vh" }}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<RulesContent content={tournament.rules || ""} />
|
||||
</Suspense>
|
||||
<Button variant="subtle" c="red" onClick={rulesSheet.close}>
|
||||
Close
|
||||
</Button>
|
||||
</Stack>
|
||||
</Sheet>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavGrid;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Anchor, Box, Group, Stack } from "@mantine/core";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import SectionHeading from "./section-heading";
|
||||
|
||||
interface RecentResultsProps {
|
||||
/** Ended matches, already sorted most-recent-first. */
|
||||
matches: Match[];
|
||||
tournamentId: string;
|
||||
/** How many to show inline before offering "View all results". */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recently finished matches, newest on top, so people can look back and react
|
||||
* (each MatchCard carries its own reaction bar). Caps the inline list and links
|
||||
* to the full bracket when there are more results than fit.
|
||||
*/
|
||||
const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const shown = matches.slice(0, max);
|
||||
const hasMore = matches.length > max;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionHeading
|
||||
label="Recently finished"
|
||||
action={
|
||||
hasMore ? (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
fw={600}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournamentId}/bracket` })
|
||||
}
|
||||
>
|
||||
<Group gap={2} align="center" wrap="nowrap">
|
||||
View all results
|
||||
<CaretRightIcon size={12} weight="bold" />
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Stack gap="sm" px="md">
|
||||
{shown.map((match) => (
|
||||
<MatchCard key={match.id} match={match} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentResults;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { MatchReport } from "@/features/bracket/components/match-report";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
import SectionHeading from "./section-heading";
|
||||
|
||||
interface ScoreReportCtaProps {
|
||||
/** Started matches the current user is playing in. */
|
||||
matches: Match[];
|
||||
}
|
||||
|
||||
const teamName = (t: Match["home"]): string =>
|
||||
t && typeof t !== "string" ? t.name : "TBD";
|
||||
|
||||
/**
|
||||
* Top-of-home call-to-action for a participant whose match is live. Composes
|
||||
* the existing <MatchReport> so reporting/confirming a score is one tap from
|
||||
* the home screen instead of buried in the bracket. Renders nothing when the
|
||||
* user isn't playing in a started match.
|
||||
*/
|
||||
const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionHeading
|
||||
label="Your match is live"
|
||||
live
|
||||
count={matches.length}
|
||||
/>
|
||||
<Stack gap="sm" px="md">
|
||||
{matches.map((match) => (
|
||||
<Paper key={match.id} withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{match.home && typeof match.home !== "string" && (
|
||||
<TeamAvatar
|
||||
team={match.home}
|
||||
size={28}
|
||||
radius="sm"
|
||||
isRegional={match.tournament.regional === true}
|
||||
/>
|
||||
)}
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{teamName(match.home)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" fw={600} style={{ flexShrink: 0 }}>
|
||||
vs
|
||||
</Text>
|
||||
<Group
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
justify="flex-end"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
ta="right"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{teamName(match.away)}
|
||||
</Text>
|
||||
{match.away && typeof match.away !== "string" && (
|
||||
<TeamAvatar
|
||||
team={match.away}
|
||||
size={28}
|
||||
radius="sm"
|
||||
isRegional={match.tournament.regional === true}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<MatchReport match={match} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreReportCta;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Group, Indicator, Text } from "@mantine/core";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
label: string;
|
||||
/** Show a pulsing red "live" dot before the label. */
|
||||
live?: boolean;
|
||||
/** Appended as "· N" after the label when greater than 1. */
|
||||
count?: number;
|
||||
/** Optional right-aligned action (e.g. a "View all" link). */
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, uppercase section label used across the started-tournament home
|
||||
* surfaces. Mirrors the original "Live Matches" heading treatment.
|
||||
*/
|
||||
const SectionHeading = ({ label, live, count, action }: SectionHeadingProps) => {
|
||||
return (
|
||||
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap" justify="space-between">
|
||||
<Group gap={10} align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{live && (
|
||||
<Indicator
|
||||
size={8}
|
||||
color="red"
|
||||
processing
|
||||
position="middle-start"
|
||||
offset={0}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em" lineClamp={1}>
|
||||
{label}
|
||||
{count && count > 1 ? ` · ${count}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{action}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionHeading;
|
||||
@@ -1,46 +1,27 @@
|
||||
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import { Box, Card, Divider, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
const StartedTournamentSkeleton = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header skeleton */}
|
||||
<Stack px="md" align="center" gap="xs">
|
||||
<Skeleton height={268} width={268} radius="lg" />
|
||||
<Skeleton height={16} width="55%" />
|
||||
</Stack>
|
||||
|
||||
{/* Match carousel skeleton */}
|
||||
<Box>
|
||||
<Group gap="xs" px="xl">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
const MatchCardSkeleton = ({ minWidth }: { minWidth: string }) => (
|
||||
<Card
|
||||
key={index}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ minWidth: "95%", flex: "0 0 auto" }}
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{ minWidth, flex: "0 0 auto" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Match header */}
|
||||
<Group justify="space-between">
|
||||
<Skeleton height={14} width="30%" />
|
||||
<Skeleton height={20} width={60} radius="xl" />
|
||||
<Skeleton height={20} width={40} radius="xl" />
|
||||
</Group>
|
||||
|
||||
{/* Teams */}
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Skeleton height={32} width={32} radius="sm" />
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={24} width={30} />
|
||||
</Box>
|
||||
</Group>
|
||||
<Center>
|
||||
<Skeleton height={14} width={20} />
|
||||
</Center>
|
||||
<Group>
|
||||
<Skeleton height={32} width={32} radius="sm" />
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={24} width={30} />
|
||||
@@ -49,20 +30,47 @@ const StartedTournamentSkeleton = () => {
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
|
||||
const StartedTournamentSkeleton = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Compact header skeleton */}
|
||||
<Group px="md" gap="md" wrap="nowrap" align="center">
|
||||
<Skeleton height={64} width={64} radius="md" />
|
||||
<Stack gap={8} style={{ flex: 1 }}>
|
||||
<Skeleton height={14} width="55%" />
|
||||
<Skeleton height={14} width="40%" />
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Live carousel skeleton */}
|
||||
<Box>
|
||||
<Skeleton height={12} width={90} ml="md" mb={10} />
|
||||
<Group gap="xs" pl="md" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<MatchCardSkeleton minWidth="88%" />
|
||||
<MatchCardSkeleton minWidth="88%" />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Actions section skeleton */}
|
||||
{/* Recently finished skeleton */}
|
||||
<Box>
|
||||
<Divider />
|
||||
<Stack gap={0}>
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={12} width={120} ml="md" mb={10} />
|
||||
<Stack gap="sm" px="md">
|
||||
<MatchCardSkeleton minWidth="100%" />
|
||||
<MatchCardSkeleton minWidth="100%" />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Nav grid skeleton */}
|
||||
<Box px="md">
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} height={72} radius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { emitServerEvent } from "@/lib/events/emitter";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { MatchInput } from "@/features/matches/types";
|
||||
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
||||
import { orderGroupStageMatches } from "./utils/match-queue";
|
||||
|
||||
export const listTournaments = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
@@ -69,12 +70,6 @@ export const enrollTeam = createServerFn()
|
||||
const team = await pbAdmin.getTeam(teamId);
|
||||
if (!team) { throw new Error('Team not found'); }
|
||||
|
||||
//const isPlayerOnTeam = team.players?.some(player => player.id === userId);
|
||||
|
||||
//if (!isPlayerOnTeam && !isAdmin) {
|
||||
// throw new Error('You do not have permission to enroll this team');
|
||||
//}
|
||||
|
||||
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
|
||||
for (const player of team.players || []) {
|
||||
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
|
||||
@@ -816,7 +811,8 @@ export const generateGroupStage = createServerFn()
|
||||
groupStageMatches.push({
|
||||
lid: -1,
|
||||
round: -1,
|
||||
order: groupStageMatches.length + 1,
|
||||
order: 0,
|
||||
|
||||
reset: false,
|
||||
bye: false,
|
||||
home: teamIds[i],
|
||||
@@ -838,6 +834,14 @@ export const generateGroupStage = createServerFn()
|
||||
}
|
||||
}
|
||||
|
||||
const orderedGroupMatches = orderGroupStageMatches(
|
||||
groupStageMatches,
|
||||
(m) => ({ groupId: m.group, teamA: m.home, teamB: m.away })
|
||||
);
|
||||
orderedGroupMatches.forEach((match, index) => {
|
||||
match.order = index + 1;
|
||||
});
|
||||
|
||||
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
||||
|
||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
export interface OrderableMatch {
|
||||
groupId: string;
|
||||
teamA: string;
|
||||
teamB: string;
|
||||
}
|
||||
|
||||
const BYE = "__BYE__";
|
||||
|
||||
function pairKey(a: string, b: string): string {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
}
|
||||
|
||||
function circleMethodRoundIndex(teams: string[]): Map<string, number> {
|
||||
const sorted = [...teams].sort();
|
||||
const arr = [...sorted];
|
||||
if (arr.length % 2 === 1) arr.push(BYE);
|
||||
|
||||
const n = arr.length;
|
||||
const result = new Map<string, number>();
|
||||
if (n < 2) return result;
|
||||
|
||||
const rounds = n - 1;
|
||||
const half = n / 2;
|
||||
let list = [...arr];
|
||||
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = list[i];
|
||||
const b = list[n - 1 - i];
|
||||
if (a !== BYE && b !== BYE) {
|
||||
result.set(pairKey(a, b), r);
|
||||
}
|
||||
}
|
||||
list = [list[0], list[n - 1], ...list.slice(1, n - 1)];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface AnnotatedMatch {
|
||||
idx: number;
|
||||
groupId: string;
|
||||
teamA: string;
|
||||
teamB: string;
|
||||
roundIndex: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function isBetter(a: number[], aKey: string, b: number[], bKey: string): boolean {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return a[i] > b[i];
|
||||
}
|
||||
return aKey < bKey;
|
||||
}
|
||||
|
||||
export function computeGroupStageOrder(matches: OrderableMatch[]): number[] {
|
||||
const n = matches.length;
|
||||
if (n <= 1) return matches.map((_, i) => i);
|
||||
|
||||
const groupTeams = new Map<string, Set<string>>();
|
||||
for (const m of matches) {
|
||||
let set = groupTeams.get(m.groupId);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
groupTeams.set(m.groupId, set);
|
||||
}
|
||||
set.add(m.teamA);
|
||||
set.add(m.teamB);
|
||||
}
|
||||
|
||||
const roundIndexByGroup = new Map<string, Map<string, number>>();
|
||||
for (const [groupId, teams] of groupTeams) {
|
||||
roundIndexByGroup.set(groupId, circleMethodRoundIndex([...teams]));
|
||||
}
|
||||
|
||||
const items: AnnotatedMatch[] = matches.map((m, idx) => {
|
||||
const round = roundIndexByGroup.get(m.groupId)?.get(pairKey(m.teamA, m.teamB));
|
||||
return {
|
||||
idx,
|
||||
groupId: m.groupId,
|
||||
teamA: m.teamA,
|
||||
teamB: m.teamB,
|
||||
roundIndex: round ?? Number.MAX_SAFE_INTEGER,
|
||||
key: `${m.groupId}|${pairKey(m.teamA, m.teamB)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const remaining = new Set<number>(items.map((it) => it.idx));
|
||||
const itemByIdx = new Map<number, AnnotatedMatch>(items.map((it) => [it.idx, it]));
|
||||
|
||||
const order: number[] = [];
|
||||
const lastPlayed = new Map<string, number>();
|
||||
const NEVER = -1 - n;
|
||||
let prevTeams = new Set<string>();
|
||||
let prevGroup: string | null = null;
|
||||
|
||||
for (let pos = 0; pos < n; pos++) {
|
||||
let best: AnnotatedMatch | null = null;
|
||||
let bestScore: number[] | null = null;
|
||||
|
||||
for (const idx of remaining) {
|
||||
const it = itemByIdx.get(idx)!;
|
||||
|
||||
const adjacent = prevTeams.has(it.teamA) || prevTeams.has(it.teamB) ? 1 : 0;
|
||||
const gapA = pos - (lastPlayed.get(it.teamA) ?? NEVER);
|
||||
const gapB = pos - (lastPlayed.get(it.teamB) ?? NEVER);
|
||||
const gap = Math.min(gapA, gapB);
|
||||
const sameGroup = prevGroup !== null && it.groupId === prevGroup ? 1 : 0;
|
||||
const score = [-adjacent, gap, -sameGroup, -it.roundIndex];
|
||||
|
||||
if (best === null || isBetter(score, it.key, bestScore!, best.key)) {
|
||||
best = it;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
order.push(best!.idx);
|
||||
remaining.delete(best!.idx);
|
||||
lastPlayed.set(best!.teamA, pos);
|
||||
lastPlayed.set(best!.teamB, pos);
|
||||
prevTeams = new Set([best!.teamA, best!.teamB]);
|
||||
prevGroup = best!.groupId;
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
export function orderGroupStageMatches<T>(
|
||||
items: T[],
|
||||
accessor: (item: T) => OrderableMatch,
|
||||
): T[] {
|
||||
const orderable = items.map(accessor);
|
||||
const order = computeGroupStageOrder(orderable);
|
||||
return order.map((idx) => items[idx]);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
function urlBase64ToBuffer(base64String: string): ArrayBuffer {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(base64);
|
||||
const buffer = new ArrayBuffer(raw.length);
|
||||
const output = new Uint8Array(buffer);
|
||||
for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export type PushPermission = "default" | "granted" | "denied";
|
||||
|
||||
export interface UseWebPush {
|
||||
supported: boolean;
|
||||
configured: boolean;
|
||||
permission: PushPermission;
|
||||
subscribed: boolean;
|
||||
busy: boolean;
|
||||
enable: () => Promise<boolean>;
|
||||
disable: () => Promise<boolean>;
|
||||
sendTest: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
return (
|
||||
(await navigator.serviceWorker.getRegistration()) ??
|
||||
(await navigator.serviceWorker.ready.catch(() => null))
|
||||
);
|
||||
}
|
||||
|
||||
export function useWebPush(): UseWebPush {
|
||||
const [supported, setSupported] = useState(false);
|
||||
const [permission, setPermission] = useState<PushPermission>("default");
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const configured = Boolean(VAPID_PUBLIC_KEY);
|
||||
|
||||
useEffect(() => {
|
||||
const isSupported =
|
||||
typeof window !== "undefined" &&
|
||||
"Notification" in window &&
|
||||
"PushManager" in window &&
|
||||
"serviceWorker" in navigator;
|
||||
setSupported(isSupported);
|
||||
if (!isSupported) return;
|
||||
|
||||
setPermission(Notification.permission as PushPermission);
|
||||
|
||||
(async () => {
|
||||
const reg = await getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
setSubscribed(Boolean(sub));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const enable = useCallback(async (): Promise<boolean> => {
|
||||
if (!supported || !configured || !VAPID_PUBLIC_KEY) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
setPermission(perm as PushPermission);
|
||||
if (perm !== "granted") return false;
|
||||
|
||||
const reg = await getRegistration();
|
||||
if (!reg) return false;
|
||||
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToBuffer(VAPID_PUBLIC_KEY),
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subscription: sub.toJSON() }),
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
|
||||
setSubscribed(true);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [supported, configured]);
|
||||
|
||||
const disable = useCallback(async (): Promise<boolean> => {
|
||||
if (!supported) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
const reg = await getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await fetch("/api/push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
}).catch(() => {});
|
||||
await sub.unsubscribe().catch(() => {});
|
||||
}
|
||||
setSubscribed(false);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [supported]);
|
||||
|
||||
const sendTest = useCallback(async (): Promise<boolean> => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/push/test", { method: "POST" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
supported,
|
||||
configured,
|
||||
permission,
|
||||
subscribed,
|
||||
busy,
|
||||
enable,
|
||||
disable,
|
||||
sendTest,
|
||||
};
|
||||
}
|
||||
@@ -17,3 +17,12 @@ export const serverConfig = {
|
||||
spotifyClientId: process.env.VITE_SPOTIFY_CLIENT_ID || '',
|
||||
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || '',
|
||||
}
|
||||
|
||||
export const vapidConfig = {
|
||||
publicKey: process.env.VAPID_PUBLIC_KEY || '',
|
||||
privateKey: process.env.VAPID_PRIVATE_KEY || '',
|
||||
subject: process.env.VAPID_SUBJECT || 'mailto:admin@flexxon.app',
|
||||
}
|
||||
|
||||
export const isPushConfigured = () =>
|
||||
Boolean(vapidConfig.publicKey && vapidConfig.privateKey)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createActivitiesService } from "./services/activities";
|
||||
import { createBadgesService } from "./services/badges";
|
||||
import { createGroupsService } from "./services/groups";
|
||||
import { createPredictionsService } from "./services/predictions";
|
||||
import { createPushService } from "./services/push";
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
@@ -50,6 +51,7 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createBadgesService(this.pb));
|
||||
Object.assign(this, createGroupsService(this.pb));
|
||||
Object.assign(this, createPredictionsService(this.pb));
|
||||
Object.assign(this, createPushService(this.pb));
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
this.authPromise.then(() => {
|
||||
@@ -129,7 +131,8 @@ interface AdminClient
|
||||
ReturnType<typeof createActivitiesService>,
|
||||
ReturnType<typeof createBadgesService>,
|
||||
ReturnType<typeof createGroupsService>,
|
||||
ReturnType<typeof createPredictionsService> {
|
||||
ReturnType<typeof createPredictionsService>,
|
||||
ReturnType<typeof createPushService> {
|
||||
authPromise: Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import { transformMatch } from "../util/transform-types";
|
||||
|
||||
export function createMatchesService(pb: PocketBase) {
|
||||
return {
|
||||
createBatch() {
|
||||
return pb.createBatch();
|
||||
},
|
||||
|
||||
async getMatch(id: string): Promise<Match | null> {
|
||||
const result = await pb.collection("matches").getOne(id, {
|
||||
expand: "tournament, home, away, home.players, away.players",
|
||||
@@ -12,7 +16,6 @@ export function createMatchesService(pb: PocketBase) {
|
||||
return transformMatch(result);
|
||||
},
|
||||
|
||||
// match Ids where the current lid is home_from_lid or away_from_lid
|
||||
async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> {
|
||||
const match = await this.getMatch(matchId);
|
||||
if (!match) throw new Error("Match not found")
|
||||
@@ -32,7 +35,6 @@ export function createMatchesService(pb: PocketBase) {
|
||||
},
|
||||
|
||||
async createMatch(data: MatchInput): Promise<Match> {
|
||||
// logger.info("PocketBase | Creating match", data);
|
||||
const result = await pb.collection("matches").create<Match>(data);
|
||||
return result;
|
||||
},
|
||||
@@ -142,6 +144,15 @@ export function createMatchesService(pb: PocketBase) {
|
||||
return results.map(match => transformMatch(match));
|
||||
},
|
||||
|
||||
async getMatchesByTournament(tournamentId: string): Promise<Match[]> {
|
||||
const results = await pb.collection("matches").getFullList({
|
||||
filter: `tournament = "${tournamentId}"`,
|
||||
expand: "tournament, home, away, home.players, away.players",
|
||||
sort: "lid",
|
||||
});
|
||||
return results.map(match => transformMatch(match));
|
||||
},
|
||||
|
||||
async getMatchesByGroup(groupId: string): Promise<Match[]> {
|
||||
logger.info("PocketBase | Getting matches for group", { groupId });
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import PocketBase from "pocketbase";
|
||||
|
||||
export interface PushSubscriptionRecord {
|
||||
id: string;
|
||||
player: string;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
user_agent?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface PushSubscriptionInput {
|
||||
player: string;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
user_agent?: string;
|
||||
}
|
||||
|
||||
export const createPushService = (pb: PocketBase) => ({
|
||||
async listPushSubscriptionsByPlayer(
|
||||
playerId: string
|
||||
): Promise<PushSubscriptionRecord[]> {
|
||||
return await pb
|
||||
.collection("push_subscriptions")
|
||||
.getFullList<PushSubscriptionRecord>({
|
||||
filter: `player = "${playerId}"`,
|
||||
});
|
||||
},
|
||||
|
||||
async getPushSubscriptionByEndpoint(
|
||||
endpoint: string
|
||||
): Promise<PushSubscriptionRecord | null> {
|
||||
try {
|
||||
return await pb
|
||||
.collection("push_subscriptions")
|
||||
.getFirstListItem<PushSubscriptionRecord>(
|
||||
`endpoint = "${endpoint}"`
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async upsertPushSubscription(
|
||||
data: PushSubscriptionInput
|
||||
): Promise<PushSubscriptionRecord> {
|
||||
const existing = await this.getPushSubscriptionByEndpoint(data.endpoint);
|
||||
if (existing) {
|
||||
return await pb
|
||||
.collection("push_subscriptions")
|
||||
.update<PushSubscriptionRecord>(existing.id, data);
|
||||
}
|
||||
return await pb
|
||||
.collection("push_subscriptions")
|
||||
.create<PushSubscriptionRecord>(data);
|
||||
},
|
||||
|
||||
async deletePushSubscriptionByEndpoint(endpoint: string): Promise<void> {
|
||||
const existing = await this.getPushSubscriptionByEndpoint(endpoint);
|
||||
if (existing) {
|
||||
await pb.collection("push_subscriptions").delete(existing.id);
|
||||
}
|
||||
},
|
||||
|
||||
async deletePushSubscriptionById(id: string): Promise<void> {
|
||||
await pb.collection("push_subscriptions").delete(id);
|
||||
},
|
||||
});
|
||||
@@ -6,9 +6,6 @@ import { Badge, BadgeInfo, BadgeProgress, EarnedBadge } from "@/features/badges/
|
||||
import { Prediction } from "@/features/predictions/types";
|
||||
import { Activity } from "../services/activities";
|
||||
|
||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
||||
// this file transforms raw pocketbase results to our types
|
||||
|
||||
export function transformPlayerInfo(record: any): PlayerInfo {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -58,6 +55,11 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
|
||||
away_seed: record.away_seed,
|
||||
match_type: record.match_type,
|
||||
group: record.group,
|
||||
reported_home_cups: record.reported_home_cups ?? undefined,
|
||||
reported_away_cups: record.reported_away_cups ?? undefined,
|
||||
reported_ot_count: record.reported_ot_count ?? undefined,
|
||||
reported_by_team: record.reported_by_team || undefined,
|
||||
reported_by_player: record.reported_by_player || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import webpush from "web-push";
|
||||
import { vapidConfig, isPushConfigured } from "@/lib/config";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { PushSubscriptionRecord } from "@/lib/pocketbase/services/push";
|
||||
|
||||
export interface PushPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
/** Path (or absolute URL) to open/focus when the notification is clicked. */
|
||||
url?: string;
|
||||
/** Optional icon override; defaults to the app icon in the SW handler. */
|
||||
icon?: string;
|
||||
/** Optional tag to collapse/replace notifications of the same kind. */
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
let vapidInitialized = false;
|
||||
|
||||
function ensureVapid(): boolean {
|
||||
if (!isPushConfigured()) {
|
||||
logger.warn(
|
||||
"Push | VAPID keys not configured (VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY); skipping send"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!vapidInitialized) {
|
||||
webpush.setVapidDetails(
|
||||
vapidConfig.subject,
|
||||
vapidConfig.publicKey,
|
||||
vapidConfig.privateKey
|
||||
);
|
||||
vapidInitialized = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level: send a single push to one subscription. Throws on transport
|
||||
* errors. Callers that persist subscriptions should handle 404/410 (dead
|
||||
* endpoint) themselves — see sendPushToPlayer.
|
||||
*/
|
||||
export async function sendPush(
|
||||
subscription: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
},
|
||||
payload: PushPayload
|
||||
): Promise<void> {
|
||||
if (!ensureVapid()) return;
|
||||
await webpush.sendNotification(subscription, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a push to every device a player has subscribed. Dead subscriptions
|
||||
* (HTTP 404/410 from the push service) are pruned from storage automatically.
|
||||
*
|
||||
* This is the seam that real product triggers will call LATER, e.g.:
|
||||
* - a match the player is in starts (src/lib/pocketbase/services/matches.ts)
|
||||
* - a free-agent claim involving the player (src/lib/pocketbase/services/free-agents ...)
|
||||
* - a badge is awarded to the player (src/lib/pocketbase/services/badges.ts)
|
||||
* NONE of these are wired yet — this foundation pass only exposes the seam
|
||||
* plus a manual "test notification" path.
|
||||
*/
|
||||
export async function sendPushToPlayer(
|
||||
playerId: string,
|
||||
payload: PushPayload
|
||||
): Promise<{ sent: number; failed: number; pruned: number }> {
|
||||
if (!ensureVapid()) return { sent: 0, failed: 0, pruned: 0 };
|
||||
|
||||
const subscriptions: PushSubscriptionRecord[] =
|
||||
await pbAdmin.listPushSubscriptionsByPlayer(playerId);
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
let pruned = 0;
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.map(async (sub) => {
|
||||
try {
|
||||
await sendPush(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||
},
|
||||
payload
|
||||
);
|
||||
sent++;
|
||||
} catch (error: any) {
|
||||
const status = error?.statusCode;
|
||||
if (status === 404 || status === 410) {
|
||||
// Subscription no longer valid — prune it.
|
||||
try {
|
||||
await pbAdmin.deletePushSubscriptionById(sub.id);
|
||||
pruned++;
|
||||
} catch (delError) {
|
||||
logger.error("Push | Failed to prune dead subscription", delError);
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
logger.error(
|
||||
`Push | Failed to send to endpoint (status ${status ?? "?"})`,
|
||||
error?.body ?? error
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { sent, failed, pruned };
|
||||
}
|
||||
Reference in New Issue
Block a user