Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19dc698f46 | ||
|
|
0a7b5fa00f | ||
|
|
22c282d8fa | ||
|
|
86cda294f9 | ||
|
|
9db3fa697d | ||
|
|
f77b3a5c41 |
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: add-language
|
||||||
|
description: Add or remove a UI language/locale — config, catalog generation, translation agents, SMS/date/meta integration, and verification. Use when asked to add, enable, support, or remove a language.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Adding a language
|
||||||
|
|
||||||
|
Example: adding French (`fr`). Five places name the locale set — update all:
|
||||||
|
|
||||||
|
1. `src/lib/i18n/index.ts` — `SUPPORTED_LOCALES` + `LOCALE_LABELS` (label in
|
||||||
|
the language itself, no region clarifier: `fr: "Français"`).
|
||||||
|
2. `lingui.config.ts` — `locales`.
|
||||||
|
3. `src/lib/mantine/mantine-provider.tsx` — `DAYJS_LOCALE` map + the matching
|
||||||
|
`import 'dayjs/locale/fr'` (calendar month/weekday names come from dayjs;
|
||||||
|
without this, calendars stay English).
|
||||||
|
4. `src/lib/i18n/meta.ts` — `OG_LOCALE_BY_LOCALE` (fr → fr_FR).
|
||||||
|
5. `src/lib/twilio/index.ts` — `TWILIO_VERIFY_LOCALES` ONLY if Twilio Verify
|
||||||
|
supports the code (https://www.twilio.com/docs/verify/supported-languages).
|
||||||
|
Unsupported codes must stay out — the allowlist silently falls back to the
|
||||||
|
service-default English SMS instead of failing the login send.
|
||||||
|
|
||||||
|
Then generate + translate:
|
||||||
|
|
||||||
|
6. `bun run extract` creates `src/locales/fr/messages.po` (every msgid, empty
|
||||||
|
msgstr).
|
||||||
|
7. Translate via a chunked agent workflow (this repo's proven recipe):
|
||||||
|
- Split en.po entry blocks (split on blank lines, skip header) into ~60-entry
|
||||||
|
chunks; one sonnet agent per chunk RETURNS translated entries (never let
|
||||||
|
parallel agents edit one .po). Validate each returned chunk by msgid count;
|
||||||
|
retry failures once.
|
||||||
|
- Merge by msgid back into the locale .po. Match msgids by exact bytes —
|
||||||
|
watch non-breaking spaces (\xa0) in msgids, which agents normalize away;
|
||||||
|
patch those few by hand.
|
||||||
|
- Agent rules: msgstr single-line, ICU placeholders/plural keywords and
|
||||||
|
`<0>` tags preserved (translate only human words inside branches; plural
|
||||||
|
categories follow the target language's CLDR set), FLXN/Flexxon/Spotify
|
||||||
|
untranslated, casual sporty tone, register decided up front (tú/du/です・ます).
|
||||||
|
8. `bun run i18n:check` must pass (0 missing). Optionally run per-locale
|
||||||
|
native-reviewer agents over the full .po (fidelity, register, glossary
|
||||||
|
consistency, ICU integrity) and apply their FIX lines.
|
||||||
|
9. `bunx tsc --noEmit && bun run build`; confirm the new locale appears as its
|
||||||
|
own lazy `messages-*.js` chunk in dist/client/assets.
|
||||||
|
10. Manual check: switch language in Settings (persists to SuperTokens
|
||||||
|
metadata), confirm UI + calendar + SMS behavior.
|
||||||
|
|
||||||
|
# Removing a language
|
||||||
|
|
||||||
|
Reverse of the above: delete from the five locale-set sites (skip Twilio if it
|
||||||
|
was never listed), `rm -rf src/locales/<code>`, drop the dayjs import, then
|
||||||
|
`bun run i18n:check` + tsc. Users with the removed locale saved in metadata
|
||||||
|
fall back to English automatically via `resolveLocale`.
|
||||||
|
|
||||||
|
Fallback semantics: untranslated entries render English (msgid); unauth
|
||||||
|
visitors get Accept-Language detection (SSR first paint on public routes is
|
||||||
|
English, corrected after hydration); authed users get their saved locale
|
||||||
|
server-rendered.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: i18n
|
||||||
|
description: Rules and workflow for user-facing strings — wrapping new text in Lingui macros, updating catalogs, and translating new entries. Use whenever adding, editing, or removing any user-visible text (components, hooks, toasts, route titles, server errors).
|
||||||
|
---
|
||||||
|
|
||||||
|
# i18n workflow (Lingui v6)
|
||||||
|
|
||||||
|
Every user-facing string must go through Lingui. English source text IS the
|
||||||
|
translation key (msgid); catalogs live in `src/locales/<locale>/messages.po`
|
||||||
|
(en, es, de, ja — es is Mexican Spanish). msgstr mirrors msgid in en.po; that
|
||||||
|
is normal for the source locale.
|
||||||
|
|
||||||
|
## Wrapping rules
|
||||||
|
|
||||||
|
Allowed APIs — context-bound only (SSR-safe):
|
||||||
|
|
||||||
|
- `import { Trans, Plural, useLingui } from "@lingui/react/macro"`
|
||||||
|
- `import { msg } from "@lingui/core/macro"` (descriptor only)
|
||||||
|
- NEVER `t`/`plural`/`select` from `@lingui/core/macro` — they bind a global
|
||||||
|
i18n and bleed locale across SSR requests.
|
||||||
|
- Carve-out: a component that only needs `i18n._()` (no `t`` macro) may use
|
||||||
|
`useLingui` from plain `@lingui/react` (see header.tsx, mantine-provider) —
|
||||||
|
it's the same context-bound hook, just without macro sugar.
|
||||||
|
|
||||||
|
Patterns:
|
||||||
|
|
||||||
|
| Context | Pattern |
|
||||||
|
|---|---|
|
||||||
|
| JSX text | `<Trans>Match starts soon</Trans>` |
|
||||||
|
| String props / expressions in a component or hook | `const { t } = useLingui()` at TOP LEVEL (unconditional — React Compiler), then `t`Saved!`` |
|
||||||
|
| Countable text in JSX | `<Plural value={n} one="# point" other="# points" />` |
|
||||||
|
| Countable text in a string expression | `i18n._({ ...msg`{n, plural, one {# team} other {# teams}}`, values: { n } })` — a bare `t`Teams (${n})`` gives translators no plural branch |
|
||||||
|
| Route header titles (loader/beforeLoad) | `title: msg`Manage {name}`, titleValues: { name }` — resolved by `Header` |
|
||||||
|
| Plain .ts utils | take an `i18n: I18n` param and use msg descriptors (see `src/features/predictions/utils.ts`) |
|
||||||
|
| Server code (API routes, push, meta) | `localizedFor(context.metadata)` from `src/lib/i18n/server-messages.ts` (or `createI18n(resolveLocale(...))`) + msg descriptors |
|
||||||
|
| Server-fn error toasts | localized centrally via the ErrorType map in `src/lib/i18n/error-messages.ts` — add new ErrorTypes there, not per-call-site |
|
||||||
|
|
||||||
|
v6 signature: values ride inside the descriptor — `i18n._({ ...msg`Hi {name}`,
|
||||||
|
values: { name } })`. `i18n._(descriptor, values)` does NOT exist in v6.
|
||||||
|
|
||||||
|
Homographs: when the same English word means different things in different
|
||||||
|
places (e.g. "Home" = nav screen vs. home team), give one of them a context so
|
||||||
|
translators get separate entries: `t({ message: "Home", context: "match team" })`
|
||||||
|
— Spanish needs "Inicio" vs "Local" there. Same-spelling msgids silently share
|
||||||
|
one translation otherwise.
|
||||||
|
|
||||||
|
Never wrap: logger/console output, query keys, route paths, URLs, CSS values,
|
||||||
|
PB collection/field names, ids/slugs, `===`-compared values, object keys,
|
||||||
|
brand names FLXN/Flexxon/Spotify, data-derived names, thrown errors that are
|
||||||
|
only logged (mapKnownError discards their text — only the ErrorType map's
|
||||||
|
strings reach users).
|
||||||
|
|
||||||
|
## Architecture invariants (violating these breaks the app, not just a string)
|
||||||
|
|
||||||
|
- **Provider order**: `LinguiProvider` must stay ABOVE `AuthProvider`
|
||||||
|
(`src/features/core/components/providers.tsx`). The localized query hooks
|
||||||
|
(`useServerQuery`/`useMe`/etc.) call `useLingui`, so anything using them —
|
||||||
|
including AuthProvider — must render inside it. LinguiProvider therefore
|
||||||
|
reads the auth query with raw `useQuery`, never `useAuth`/`useMe`.
|
||||||
|
- **`__root` head() must not read `match`/context** — doing so creates
|
||||||
|
circular route-type inference that breaks `beforeLoad` typing in child
|
||||||
|
routes. Meta stays DEFAULT_LOCALE (crawlers have no session anyway).
|
||||||
|
- **Babel order** in vite.config.ts: lingui macro plugin BEFORE
|
||||||
|
react-compiler. Reversed order fails the build on `<Plural>`.
|
||||||
|
- Catalog loading: `__root` beforeLoad preloads the active locale on authed
|
||||||
|
routes; `LinguiProvider` self-loads a missing catalog client-side (public
|
||||||
|
routes). Unauthenticated non-en visitors get an English SSR first paint on
|
||||||
|
/login that corrects after hydration — known, accepted.
|
||||||
|
- `useMemo`/`useCallback` bodies that CACHE a computed string from `t`/`i18n`
|
||||||
|
must list them in deps. (Callbacks that merely *call* `t` when invoked read
|
||||||
|
the live locale and are safe.)
|
||||||
|
- Tab labels: `SwipeableTabs` items need a stable `value` slug; `label` is
|
||||||
|
the translated display string.
|
||||||
|
|
||||||
|
## Catalog workflow — run after any string change
|
||||||
|
|
||||||
|
1. `bun run extract` — regenerates ALL locale .po files from source, adds new
|
||||||
|
msgids, removes stale ones. Never hand-edit msgids or en.po.
|
||||||
|
2. Translate the new empty `msgstr ""` entries in es/de/ja. Spawn one
|
||||||
|
translation agent per locale (batch ~60 entries; have them RETURN the
|
||||||
|
translated entries and merge by msgid — don't let parallel agents edit one
|
||||||
|
file). Translation rules for agents:
|
||||||
|
- msgid byte-identical; translate msgstr only, single line.
|
||||||
|
- Preserve ICU placeholders/plural structure (translate only the words
|
||||||
|
inside branches) and `<0>`/`</0>` tags exactly.
|
||||||
|
- Watch invisible characters: msgids can contain non-breaking spaces
|
||||||
|
(\xa0) that agents silently normalize — verify by byte comparison,
|
||||||
|
not visual.
|
||||||
|
- Registers: es = informal tú (Mexican), de = informal du, ja =
|
||||||
|
polite-casual です/ます. FLXN/Flexxon/Spotify untranslated.
|
||||||
|
3. `bun run i18n:check` — fails if any locale has missing translations. CI
|
||||||
|
(`.gitea/workflows/ci-cd.yaml`, `i18n-check` job) enforces this AND fails
|
||||||
|
if committed catalogs are stale vs. source — always commit `src/locales`
|
||||||
|
with string changes.
|
||||||
|
4. `bun run build` runs extract automatically, so a forgotten extract can't
|
||||||
|
ship hash-ids — but only step 3 catches untranslated entries.
|
||||||
|
|
||||||
|
Production strips English defaults from components; text lives only in the
|
||||||
|
hash-keyed compiled catalog, so a msgid missing from the catalog renders as
|
||||||
|
its hash. Non-en catalogs are separate lazy chunks — English users download
|
||||||
|
nothing extra.
|
||||||
@@ -7,9 +7,50 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
i18n-check:
|
||||||
|
name: i18n Catalog Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Bun
|
||||||
|
run: |
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
(command -v apt-get >/dev/null && apt-get update && apt-get install -y unzip) || \
|
||||||
|
(command -v apk >/dev/null && apk add --no-cache unzip)
|
||||||
|
fi
|
||||||
|
arch=$(uname -m)
|
||||||
|
case "$arch" in x86_64) target=x64 ;; aarch64|arm64) target=aarch64 ;; *) echo "unsupported arch: $arch"; exit 1 ;; esac
|
||||||
|
# musl images (Alpine) need the -musl build; the glibc one fails with spawn ENOENT
|
||||||
|
libc=""
|
||||||
|
if ldd --version 2>&1 | grep -qi musl; then libc="-musl"; fi
|
||||||
|
curl -fsSL -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${target}${libc}.zip"
|
||||||
|
unzip -q -o /tmp/bun.zip -d /tmp/bun-extract
|
||||||
|
mkdir -p "$HOME/.bun/bin"
|
||||||
|
mv /tmp/bun-extract/bun-linux-*/bun "$HOME/.bun/bin/bun"
|
||||||
|
chmod +x "$HOME/.bun/bin/bun"
|
||||||
|
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||||
|
"$HOME/.bun/bin/bun" --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Catalogs in sync with source
|
||||||
|
run: |
|
||||||
|
bunx lingui extract --clean
|
||||||
|
git diff --exit-code src/locales || {
|
||||||
|
echo "::error::Locale catalogs are stale — run 'bun run extract' and commit src/locales"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: No missing translations
|
||||||
|
run: bunx lingui compile --strict
|
||||||
|
|
||||||
build-app:
|
build-app:
|
||||||
name: Build and Push App Docker Image
|
name: Build and Push App Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: i18n-check
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -21,3 +21,5 @@ yarn.lock
|
|||||||
/pb_data/
|
/pb_data/
|
||||||
/.tanstack/
|
/.tanstack/
|
||||||
/dist/
|
/dist/
|
||||||
|
# lingui compile output (i18n:check); runtime uses .po via vite plugin
|
||||||
|
src/locales/*/messages.js
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# FLXN
|
||||||
|
|
||||||
|
## i18n (Lingui v6) — applies to every user-facing string
|
||||||
|
|
||||||
|
Any change that adds, edits, or removes user-visible text must follow the
|
||||||
|
`i18n` skill (`.claude/skills/i18n/SKILL.md`): wrap with context-bound Lingui
|
||||||
|
macros, then `bun run extract`, translate new entries in every non-`en`
|
||||||
|
locale, and `bun run i18n:check`. Adding a language: use the `add-language`
|
||||||
|
skill.
|
||||||
|
|
||||||
|
Hard rules (full detail in the skill):
|
||||||
|
|
||||||
|
- Only `Trans`/`Plural`/`useLingui` from `@lingui/react/macro` and `msg` from
|
||||||
|
`@lingui/core/macro`. Never global `t`/`plural`/`select` from core/macro
|
||||||
|
(SSR locale bleed).
|
||||||
|
- `useLingui()` at top level of components/hooks only; add `t`/`i18n` to memo
|
||||||
|
dep arrays.
|
||||||
|
- Never hand-edit msgids or `src/locales/en/messages.po` (generated).
|
||||||
|
- vite.config.ts babel plugin order (lingui before react-compiler) is
|
||||||
|
load-bearing.
|
||||||
|
- `LinguiProvider` stays ABOVE `AuthProvider` in providers.tsx (localized
|
||||||
|
query hooks call useLingui), and `__root`'s `head()` must never read
|
||||||
|
`match`/context (circular route-type inference breaks beforeLoad typing).
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# FLXN Replay — Design Spec (v1, locked)
|
||||||
|
|
||||||
|
**Status:** design locked, not yet built. The tournament recap feature: a **player-owned recap** and an **overall tournament recap**, viewable **retroactively** from any completed tournament. Content is shaped to render into a **shareable card layout** (the owner's friend is producing the SVG/PNG graphic template — we spec the payload, not the artwork).
|
||||||
|
|
||||||
|
**No new collection.** Everything below is derivable at read time from existing data — the same philosophy as the podium/placement derivation in `src/lib/pocketbase/util/transform-types.ts` (`transformTournament` computes `isComplete`, `first/second/third_place` purely from ended matches). Replay is a pair of server fns that compute a recap payload from data we already store.
|
||||||
|
|
||||||
|
## Availability
|
||||||
|
- A tournament has a Replay when it's **complete**: every non-bye match `status === "ended"` — reuse the exact `isComplete` derivation from `transformTournament` (`src/lib/pocketbase/util/transform-types.ts:218-219`).
|
||||||
|
- **Overall recap is public** (any authed user). **Player recap** exists for every enrolled player; the "View Replay" entry shows participants their own recap.
|
||||||
|
- Retroactive by construction: old completed tournaments get Replays for free since nothing is written at tournament end.
|
||||||
|
|
||||||
|
## Data sources (all existing)
|
||||||
|
- **Matches** — `status: "ended"`, `home_cups`/`away_cups`, `ot_count`, `start_time`/`end_time`, `home_seed`/`away_seed`, `round`, `lid`, `order`, `is_losers_bracket`, `reset`, `bye` (`src/features/matches/types.ts`, transformed at `src/lib/pocketbase/util/transform-types.ts:30`). `pbAdmin.getTournament(id)` already returns matches + teams expanded, plus `team_stats` from the `team_stats_per_tournament` view (`src/lib/pocketbase/services/tournaments.ts:21`).
|
||||||
|
- **Stats views** — `team_stats_per_tournament` gives per-team W/L, cups for/against, margins for this event (mapped onto `tournament.team_stats` in `transformTournament`). Global `player_stats` / `player_mainline_stats` / `player_regional_stats` views exist (`pb_migrations/1783926000_optimized_player_stats_views.js`) but are all-time; per-event player numbers come from the tournament's own matches (teams are 2-player, so a player's event record == their team's record).
|
||||||
|
- **Reactions** — `reactions` collection `{match, player, emoji}` (`src/lib/pocketbase/services/reactions.ts`). Currently only fetched per match; add one service method fetching all reactions for a tournament via filter `match.tournament = "<id>"`.
|
||||||
|
- **Predictions** — `computePredictionScore` (points, correct, `predictedChampionId`) in `src/features/predictions/utils.ts` and `getPredictionsLeaderboard` in `src/features/predictions/server.ts` give points / rank / called-the-champ directly.
|
||||||
|
- **Badges** — `badge_progress` (`earned` boolean, no per-tournament attribution) — see open questions.
|
||||||
|
|
||||||
|
## Player recap (content + derivation)
|
||||||
|
All from the completed tournament's ended matches involving the player's team (find the team by scanning `tournament.teams[].players` for the player id):
|
||||||
|
- **W–L + win %** — team's rows from `tournament.team_stats`, or count from matches.
|
||||||
|
- **Cups sunk / conceded** — `total_cups_made` / `total_cups_against` from `team_stats`.
|
||||||
|
- **Biggest blowout margin** — max `|home_cups - away_cups|` across their wins, with opponent.
|
||||||
|
- **Longest win streak** — sort their matches by `order` (fallback `end_time`), scan for the longest run of wins.
|
||||||
|
- **Nemesis** — the team that ended their tournament (opponent in their final loss; in double-elim that's their losers-bracket exit), or if undefeated/champion, worst H2H by aggregate cup differential.
|
||||||
|
- **Victim** — opponent with the biggest aggregate cup differential in the player's favor across H2H games.
|
||||||
|
- **OT games survived** — their ended matches with `ot_count >= 1`; show count and OT record.
|
||||||
|
- **Final placement + podium** — top 3 from the existing podium derivation; below that, label by exit round ("Eliminated in Match 12" / round name via `getMatchLabel`-style logic) rather than a fake numeric rank.
|
||||||
|
- **Partner chemistry** — teammate (the other id on the roster) + combined record; identical to team record, framed as "You and X went 4–1 together".
|
||||||
|
- **Prediction result** — points / rank / champion-call from the predictions leaderboard; omit the block if they didn't submit.
|
||||||
|
- **Most-reacted match** — their match with the most reaction rows; include the top emoji.
|
||||||
|
- **Badges earned this event** — see open questions; v1 approach below.
|
||||||
|
|
||||||
|
## Overall recap (content + derivation)
|
||||||
|
- **Champion + podium** — existing `first/second/third_place` derivation.
|
||||||
|
- **Biggest upset** — among ended matches where both `home_seed` and `away_seed` are set, the win with the largest (winner seed − loser seed) gap.
|
||||||
|
- **Highest-scoring match** — max `home_cups + away_cups`. **Closest** — min nonzero margin (tie-break: more total cups). **Longest** — max `ot_count` (tie-break: `end_time - start_time` when both set).
|
||||||
|
- **Most cups in one game (single team)** — max of `home_cups`/`away_cups` with team + match.
|
||||||
|
- **Totals** — Σ cups, count of ended matches, count with `ot_count >= 1` (+ total OT periods).
|
||||||
|
- **MVP** — best `team_stats` row by win %, then margin (cup differential); surfaced as the two players of that team (or the champion team — tunable).
|
||||||
|
- **Most-reacted moment** — match with most reactions overall + top emoji.
|
||||||
|
- **Prediction-pool winner** — top of `getPredictionsLeaderboard`; omit if nobody predicted.
|
||||||
|
- **Team count / attendance** — `tournament.teams.length` / unique players across rosters.
|
||||||
|
|
||||||
|
## Share-card layout
|
||||||
|
- The recap payload is a list of **discrete stat blocks** (`{ key, label, value, sublabel?, team?/player?, matchRef? }`) so the external SVG/PNG template can consume the same JSON the app renders — don't bake copy into components.
|
||||||
|
- In-app, render as a vertical stack of card sections (hero: placement/champion; then blocks), constrained to a portrait share-card aspect so what you see is what the graphic shows.
|
||||||
|
- v1 sharing: the friend's template consumes the payload out-of-band; an in-app "Share" (Web Share API / rendered PNG) is explicitly **not** in v1 scope — just keep the payload shape stable.
|
||||||
|
|
||||||
|
## Entry points
|
||||||
|
- `TournamentStats` (`src/features/tournaments/components/tournament-stats.tsx`) already renders the completed-tournament overview with `ListLink` rows ("View Bracket", "View Predictions" ~lines 178-196). Add **"View Replay"** there, gated on the same `isComplete` memo already computed in that file. It links to the overall recap; inside, a participant sees a "Your Replay" hero button to their own player recap.
|
||||||
|
|
||||||
|
## Reuses / dependencies
|
||||||
|
- `pbAdmin.getTournament` (one fetch covers matches, teams, team_stats), podium derivation, predictions scoring + leaderboard, reactions collection, `ListLink`, `SwipeableTabs`/sheet patterns, `useServerSuspenseQuery`.
|
||||||
|
- No dependency on any Wave 2/3 feature; ships standalone.
|
||||||
|
|
||||||
|
## Implementation notes for a fresh agent (build this cold)
|
||||||
|
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/flxn-replay.md` (this file) fully first, then:
|
||||||
|
- **No migration.** Replay writes nothing. Do not add a collection.
|
||||||
|
- **New feature dir:** `src/features/replay/{types.ts, server.ts, queries.ts, utils.ts, components/}`. Keep every derivation (streaks, nemesis/victim, upset, superlatives) as pure functions over `Match[]`/`TournamentTeamStats[]` in `utils.ts`, unit-testable like `src/features/predictions/utils.ts`.
|
||||||
|
- **Server fns:** two — `getTournamentRecap(tournamentId)` and `getPlayerRecap({tournamentId, playerId})` — following `createServerFn().validator(zod).middleware([...]).handler(...)` + `toServerResult`, with `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])`; copy the shape of `getPredictionsLeaderboard` in `src/features/predictions/server.ts` (it already does getTournamentOrThrow + derive-from-matches). Resolve the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)`. Throw if the tournament isn't complete (reuse the `isComplete` logic; export it from a shared util rather than copy-pasting a third time — it already exists twice in `transform-types.ts`).
|
||||||
|
- **Reactions per tournament:** add `getReactionsForTournament(tournamentId)` to `src/lib/pocketbase/services/reactions.ts` — `getFullList({ filter: \`match.tournament="${id}"\` })` — and aggregate counts per match in `utils.ts`.
|
||||||
|
- **Prediction block:** call the existing prediction server logic (or its underlying service + `computePredictionScore`) inside the recap fn; don't re-implement scoring.
|
||||||
|
- **Badges block (v1):** fetch the player's `badge_progress` where `earned = true` and `updated` falls within `[tournament.start_time, tournament.end_time + 24h]` — attribution by timestamp. This is best-effort (see open questions); render nothing when empty.
|
||||||
|
- **Routes:** `src/app/routes/_authed/tournaments/$id.replay.tsx` (overall) and `src/app/routes/_authed/tournaments/$id.replay_.$playerId.tsx` (player), mirroring `$id.predictions.tsx` / `$id.predictions_.$playerId.tsx` exactly (same `createFileRoute` + loader header + Suspense pattern; note sub-routes use `$id`, only the profile route uses `$tournamentId`).
|
||||||
|
- **Client data:** `useServerSuspenseQuery` with a `replayKeys`/`replayQueries` module copying `src/features/predictions/queries.ts`. Data is immutable post-completion — no SSE wiring, no invalidation, long `staleTime` is fine.
|
||||||
|
- **Entry point:** in `tournament-stats.tsx`, add `{isComplete && <ListLink label="View Replay" to={\`/tournaments/${tournament.id}/replay\`} Icon={...} />}` next to the existing View Bracket/Predictions links. Skip it for `tournament.regional` (old regional data is flagged unreliable in that same file).
|
||||||
|
- **Design system:** Mantine 8 + app theme (`src/lib/mantine/mantine-provider.tsx`: `defaultRadius: "sm"`, primary = user accent, press feedback via the `flxn-press` activeClassName). The recap should feel like a highlight reel, not a table — but tasteful motion only (iOS-like easing, 120–350ms), accent color reserved for the hero stat per card, and match neighboring components rather than inventing a new look. Reuse `TeamAvatar`/`PlayerAvatar` in stat blocks.
|
||||||
|
- **Toasts:** none needed (read-only feature); errors surface via the route's error boundary.
|
||||||
|
|
||||||
|
## Tunables / open questions
|
||||||
|
- **Badge attribution** is the one shaky derivation: `badge_progress.earned` is a boolean with no event linkage, and retroactive/recomputed badges (`migrateBadgeProgress`) break timestamp inference. v1 = timestamp window (above); the clean fix is re-running badge criteria with/without this tournament's matches and diffing — decide if that's worth it before building.
|
||||||
|
- **Biggest upset without seeds:** group-stage matches (`round === -1`) and some formats lack `home_seed`/`away_seed`; v1 skips seedless matches. Fall back to group placement later?
|
||||||
|
- **Player recap visibility:** owner-only vs. public like `$id.predictions_.$playerId.tsx`? Default: viewable by any participant (it's all derivable from public match data anyway), but the entry point only advertises your own.
|
||||||
|
- **"OT games survived"** — count OT games played, or only OT wins? Default: played, with the win/loss split shown.
|
||||||
|
- **MVP definition** — best win % then margin (as specced) vs. simply the champions. Revisit after seeing real output.
|
||||||
|
- **Regional tournaments** — excluded in v1 (data quality); could be enabled per-tournament later.
|
||||||
|
- **Non-podium placement copy** — exit-round label vs. numeric rank; locked to label for v1.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Free-Agent Matchmaking Board — Design Spec (v1, locked)
|
||||||
|
|
||||||
|
**Status:** design locked, not yet built. Extends the existing free-agent enrollment flow; the admin random-pairing flow stays as the fallback for anyone unmatched.
|
||||||
|
|
||||||
|
Players who sign up without a team ("free agents") can see each other — with stats and badges — and **claim a partner**. The claimed player must **confirm** (two-sided handshake, mirroring the score-report Confirm pattern). On mutual accept the app creates the team for them. The admin still arbitrates leftovers.
|
||||||
|
|
||||||
|
## What exists today (baseline)
|
||||||
|
- `free_agents` collection (`pbc_2929550049`): `{ player → players, tournament → tournaments, phone }`. Created in `pb_migrations/1758388728_created_free_agents.js`, tournament relation added in `1758402128_updated_free_agents.js`. No partner/status field, no claim concept.
|
||||||
|
- Service: `enrollFreeAgent` / `unenrollFreeAgent` / `getFreeAgents` in `src/lib/pocketbase/services/tournaments.ts` (~L189–216); `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` returns `{ id, phone, player }`.
|
||||||
|
- Server fns in `src/features/tournaments/server.ts`: `getFreeAgents` (L109, any authed user — note it currently returns phones for everyone), `enrollFreeAgent` (L116), `unenrollFreeAgent` (L131). All emit `{ type: "tournament", tournamentId }`.
|
||||||
|
- UI: `src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx` (enroll sheet) and `enrolled-free-agent.tsx` (the pool view). **Regional agents can't see the pool at all** — the `isRegional` branch shows only your own card with "Partners will be randomly assigned". Non-regional agents see a plain name + phone list. No stats, no badges, no actions.
|
||||||
|
- Admin fallback: `src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx` → `useGenerateRandomTeams` / `useConfirmTeamAssignments` → `generateRandomTeams` (L146) and `confirmTeamAssignments` (L326) in `src/features/tournaments/server.ts`. Confirm finds-or-creates a `private: true` team per pair, `enrollTeam`s it, then `unenrollFreeAgent`s both players.
|
||||||
|
- The two-sided confirm precedent: `reportMatchScore` / `confirmMatchScore` in `src/features/matches/server.ts` (~L828–928). Pending state lives **on the record itself** (`reported_by_team`, `reported_home_cups`, …), the other side confirms, and a cross-report with identical values auto-finalizes.
|
||||||
|
|
||||||
|
## The board
|
||||||
|
Replaces the plain list inside `enrolled-free-agent.tsx` for **both** regional and mainline tournaments (the regional visibility gate is relaxed — see below). Each agent renders as a card:
|
||||||
|
- `PlayerAvatar` + name.
|
||||||
|
- **Stats strip:** matches, win %, avg cups/match (from `PlayerStats` — `src/features/players/types.ts` L34).
|
||||||
|
- **Badges row:** the player's earned badges, compact (icon row, overflow "+N").
|
||||||
|
- **Action:** one of `Claim` / `Claimed — waiting` (yours, with Withdraw) / `Accept · Decline` (they claimed you) / a subtle "pending with someone else" state.
|
||||||
|
- Phone: keep today's behavior for mainline (copy-to-clipboard affordance); regionals stay phone-less (`enrollFreeAgent` is already called with `""` phone via the regional path, and admin-enroll at L737 passes `""`).
|
||||||
|
|
||||||
|
Fetch stats/badges in bulk — `getAllPlayerStats` (`src/features/players/server.ts` L174) and `getAllEarnedBadges` (`src/features/badges/server.ts`, keyed in `src/features/badges/queries.ts`) — filter client-side to the agents on the board. Do **not** issue per-agent `getPlayerStats`/`getPlayerBadges` calls (N+1).
|
||||||
|
|
||||||
|
## The handshake
|
||||||
|
1. **Claim:** agent A taps Claim on agent B. A's `free_agents` row gets `claimed = <B's row id>`. A can have **one outgoing claim** at a time (withdraw to switch).
|
||||||
|
2. **B sees it** (SSE-refreshed): "A wants to partner with you" with Accept / Decline. B can hold multiple *incoming* claims; accepting one implicitly declines the rest.
|
||||||
|
3. **Accept →** server verifies A's claim still points at B, then creates the team (reusing the `confirmTeamAssignments` find-or-create logic), enrolls it in the tournament, and `unenrollFreeAgent`s both — both rows vanish from the pool, exactly like the admin flow.
|
||||||
|
4. **Decline →** clears A's `claimed` field. A is free to claim someone else. (Optionally notify A — see tunables.)
|
||||||
|
5. **Symmetry shortcut:** if B already has an outgoing claim on A when A claims B (or vice versa), that *is* mutual intent — auto-accept immediately, mirroring how `reportMatchScore` auto-finalizes on an identical cross-report.
|
||||||
|
|
||||||
|
Team naming: default `"<A first> & <B first>"`, same shape the random generator produces; rename later via the existing team-edit flow.
|
||||||
|
|
||||||
|
## Data model (decided: Option A — field on `free_agents`)
|
||||||
|
Two options were weighed:
|
||||||
|
|
||||||
|
- **Option A (chosen): a self-relation on `free_agents`.** Add one nullable field `claimed` (relation → `free_agents`, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row's `claimed`; my incoming claims = rows where `claimed = <my row id>`; decline = clear the field; match = both rows deleted. No status enum needed for v1 (one outgoing claim, terminal states delete rows). This mirrors the score-report precedent where pending state lives on the `matches` record, and unenroll/delete automatically cleans up outgoing claims (incoming pointers to a deleted row are cleared server-side on unenroll — one filtered update).
|
||||||
|
- **Option B (rejected for v1): a `partner_claims` collection** `{ tournament, from_agent, to_agent, status: pending|accepted|declined, created }`. Buys an audit trail, decline history (anti-re-spam), and native multi-claim — at the cost of a new collection, cross-record consistency, and cleanup hooks on unenroll. Revisit if tunables land on "multiple outgoing claims" or "declines block re-claiming".
|
||||||
|
|
||||||
|
`getFreeAgents` grows to expand `claimed` and return `{ id, phone, player, claimedAgentId }`; the board derives the four card states from that plus "who points at me".
|
||||||
|
|
||||||
|
## Server fns (new, in `src/features/tournaments/server.ts`)
|
||||||
|
All player-facing: `createServerFn().validator(zod).middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]).handler(...)` + `toServerResult`, resolving the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)` — copy `enrollFreeAgent` (L116) as the shell and the guard style of `confirmMatchScore`.
|
||||||
|
|
||||||
|
- `proposePartner({ tournamentId, targetAgentId })` — caller must be an enrolled agent with no outgoing claim; target must be a different agent in the same tournament. Sets `claimed`. If target already claims caller → run the accept path (auto-match). Emits `tournament`; push to target.
|
||||||
|
- `withdrawPartnerClaim({ tournamentId })` — clears caller's `claimed`. Emits `tournament`.
|
||||||
|
- `respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean })` — verifies `fromAgent.claimed === callerAgent.id` (claim may have been withdrawn — fail with a friendly error). Decline: clear it. Accept: **reuse the team path** — extract the find-or-create-private-team + `enrollTeam` block from `confirmTeamAssignments` (L341–381) into a shared helper (e.g. `createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)`), call it, then `unenrollFreeAgent` both (which drops both rows and any pointers). Emit `team` + `tournament`; push to the claimer.
|
||||||
|
|
||||||
|
Race safety: re-read both rows inside the handler before mutating (same optimistic pattern as score confirm — no locks, just verify-then-act; PB writes are serialized enough at this app's scale, per the existing flows).
|
||||||
|
|
||||||
|
## Realtime + push
|
||||||
|
- **SSE:** reuse the existing `tournament` event (`src/lib/events/emitter.ts` L25). The handler in `src/hooks/use-server-events.ts` (L51) already invalidates the `['tournaments']` prefix, which covers `tournamentKeys.free_agents(id)` (`['tournaments','free_agents',id]`, `src/features/tournaments/queries.ts` L9) — **no new event type needed**; the board updates live as claims land.
|
||||||
|
- **Push:** `sendPushToPlayer` in `src/lib/push/index.ts` — its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (`PushPayload` in `src/lib/push/types.ts`):
|
||||||
|
- On claim → target: `{ title: "You've been claimed!", body: "<Name> wants to be your partner for <tournament>", url: "/", tag: "partner-claim-<agentId>" }`.
|
||||||
|
- On accept → claimer: `{ title: "It's a match", body: "<Name> accepted — you're teammates", ... }`.
|
||||||
|
- Decline: **no push** (avoid rejection stings; the cleared state shows on the board).
|
||||||
|
|
||||||
|
## Regional visibility gate (relaxed)
|
||||||
|
Delete the regional "you're enrolled, wait for assignment" dead-end in `enrolled-free-agent.tsx` (L45–74): regionals get the same board (stats, badges, claim) — that self-card + "Partners will be randomly assigned when enrollment closes" collapses into the board's header copy ("Claim a partner, or the admin pairs whoever's left"). Keep the regional enroll sheet copy in `enroll-free-agent.tsx` but update it to mention claiming. Phone stays hidden for regionals (the claim button replaces its purpose).
|
||||||
|
|
||||||
|
## Admin fallback interplay
|
||||||
|
- Accepted handshakes remove both agents from `free_agents`, so `assign-partners.tsx` and `generateRandomTeams` see only the leftovers — **zero changes to the pairing math**. The even-count and ≥2 guards (L156–162) still apply to what remains.
|
||||||
|
- Pending (unaccepted) claims are invisible to the random pairer — it may split a pending pair. Acceptable for v1: random pairing runs at deadline, and `confirmTeamAssignments`'s `unenrollFreeAgent` sweep clears all claim state. Optional nicety (cheap, do it): `generateRandomTeams` seeds mutually-irrelevant pairs randomly but **keeps any pending claim pair together** when both are still in the pool — a one-pass pre-grouping before the shuffle.
|
||||||
|
- Admin's enrolled-players count and `assign-partners` header need no changes; they read the same `useFreeAgents` query.
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
- Mantine 8 + the app theme (`src/lib/mantine/mantine-provider.tsx`): `defaultRadius: "sm"`, dynamic `primaryColor`. Use `Card withBorder radius="md" p="md"` like the existing regional self-card so the board matches its neighbors.
|
||||||
|
- Accent discipline: **one** accented element per card — the `Claim` button. Waiting/pending states are `variant="subtle"`/`c="dimmed"`; Accept is filled, Decline is `variant="subtle" color="red"` (matching the enroll sheet's Cancel).
|
||||||
|
- Press feedback via the `flxn-press` theme; motion only where it earns it — a single 120–350ms iOS-eased transition when a card flips state (claimed → matched), no confetti, no bouncing.
|
||||||
|
- Badges row reuses the visual language of `src/features/badges/components/badge-showcase.tsx` at small size — don't invent a new badge chip.
|
||||||
|
|
||||||
|
## Implementation notes for a fresh agent (build this cold)
|
||||||
|
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/free-agents.md` (this file) fully first, then:
|
||||||
|
- **Migration:** one new `pb_migrations/*_updated_free_agents.js` adding the `claimed` self-relation (collectionId `pbc_2929550049`, maxSelect 1, required false, cascadeDelete false). Mimic `1758402128_updated_free_agents.js` (plain `migrate((app)=>{...})` with up + down). Players collection id is `pbc_3072146508`, tournaments `pbc_340646327`, teams `pbc_1568971955`. Collections have null rules; all access is server-side via `pbAdmin`.
|
||||||
|
- **Service layer:** extend `createTournamentsService` in `src/lib/pocketbase/services/tournaments.ts` — `setFreeAgentClaim(agentId, targetAgentId|null)`, `getFreeAgentByPlayer(playerId, tournamentId)` (the lookup already inlined in `unenrollFreeAgent` L198), and make `unenrollFreeAgent` also clear inbound pointers (`getFullList({ filter: 'claimed = "<rowId>"' })` → clear each). Update `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` to surface `claimedAgentId`.
|
||||||
|
- **Server fns:** the three fns above in `src/features/tournaments/server.ts`, next to `enrollFreeAgent`. Extract `createOrReuseTeamForPair` from `confirmTeamAssignments` (L341–381) as a module-level helper and call it from both places — do not fork the team-creation logic. Emit `emitServerEvent({ type: "tournament", tournamentId })` (and `{ type: "team" }` on match) exactly like the existing fns; fire `sendPushToPlayer` (`src/lib/push/index.ts`) after the write, non-blocking (`.catch(log)`), never failing the request.
|
||||||
|
- **Client data:** hooks in `src/features/tournaments/hooks/` following `use-enroll-free-agent.ts` (uses `useServerMutation` from `src/lib/tanstack-query/hooks`, invalidates `tournamentKeys.free_agents(id)`). Board reads `useFreeAgents` + `getAllPlayerStats` / `getAllEarnedBadges` via new bulk queries keyed like `src/features/players/queries.ts` / `src/features/badges/queries.ts`. No new SSE event type: `use-server-events.ts`'s `tournament` handler already invalidates the `['tournaments']` prefix.
|
||||||
|
- **UI:** rework `enrolled-free-agent.tsx` into a `FreeAgentBoard` (new file under the same dir; keep `unenroll-free-agent.tsx` at the bottom). Card = `PlayerAvatar` + name + stats strip + badge row + action slot. Confirm-style incoming-claim UI: filled Accept, subtle red Decline, same layout rhythm as the score-confirm affordance in the match card. Sheets via `@/components/sheet/sheet` + `useSheet`, buttons via `@/components/button`.
|
||||||
|
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only. "Claim sent", "You're teammates!", and a friendly error when a claim was withdrawn/taken ("They just partnered up — pick someone else").
|
||||||
|
- **Copy updates:** `enroll-free-agent.tsx` sheet text (both branches) now mentions the board + claiming; delete the regional-only self-card branch in the pool view.
|
||||||
|
- **Design system:** Mantine 8 + the app theme; `defaultRadius: "sm"`; press feedback via `flxn-press`; one accent per card; match neighboring components, don't invent a new look.
|
||||||
|
- **Verify:** enroll two players as free agents (one regional tournament, one mainline), claim, accept — confirm a `private: true` team appears on the tournament, both agents leave the pool, and `assign-partners` shows only leftovers. Then run the admin random flow on an even leftover pool to prove the fallback is untouched.
|
||||||
|
|
||||||
|
## Tunables / open questions
|
||||||
|
- **Multiple outgoing claims?** v1: one at a time (keeps Option A trivially consistent). If demand appears, move to Option B's collection.
|
||||||
|
- **Claim expiry?** v1: none — claims live until withdrawn, declined, or the pool is swept. Candidate: auto-expire via `updated` age (e.g. 24h) checked lazily in `getFreeAgents`.
|
||||||
|
- **Does admin auto-pair leftovers at a deadline?** Today it's manual (admin opens assign-partners and generates). Keep manual for v1; a scheduled auto-pair is a separate feature.
|
||||||
|
- **Decline memory:** should a declined claimer be blocked from re-claiming the same person? v1: no (rows carry no history). Option B enables it.
|
||||||
|
- **Keep pending pairs together in random pairing?** Recommended-cheap, but genuinely optional — cut it if it complicates the shuffle.
|
||||||
|
- **Phone visibility:** mainline keeps phones on the board for now; consider hiding until matched once claiming proves out.
|
||||||
|
- **Push copy/tag collapsing:** `tag: "partner-claim-<agentId>"` collapses repeat claims from the same person; revisit if it feels spammy.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Push Notifications — Ideas Board (nothing decided)
|
||||||
|
|
||||||
|
**Status:** brainstorm, NOT a spec. The push *foundation* is built; **zero product triggers are wired**. This doc is a menu of candidate notification types to pick from later — none of them are commitments, and the list is meant to be argued with, pruned, and added to.
|
||||||
|
|
||||||
|
## Guiding principle (the one non-negotiable)
|
||||||
|
|
||||||
|
**Minimal by default. Strictly opt-in. Do not annoy.**
|
||||||
|
|
||||||
|
- Every notification type ships **OFF** unless the user opts in — and even opted-in volume stays low.
|
||||||
|
- Push is a scarce resource: each notification spends trust. One annoying ping and people nuke permission at the OS level and we never get it back.
|
||||||
|
- Only a **tiny high-value default-on set** for users who flip the master toggle. Current candidates: **"you're up next"** and **"you were claimed as a partner"** — both rare, both personally about *you*, both actionable right now.
|
||||||
|
- Prefer notifications that are: about the recipient personally > rare > time-sensitive > tappable to something useful. Anything failing two of those four probably shouldn't exist.
|
||||||
|
|
||||||
|
## What already exists (the foundation)
|
||||||
|
|
||||||
|
- **`src/lib/push/index.ts`** — `sendPushToPlayer(playerId, { title, body, url?, icon?, tag? })`. Sends to every device the player subscribed, auto-prunes dead endpoints (404/410). The file itself documents the trigger seams as **deliberately unwired**.
|
||||||
|
- **`push_subscriptions` collection** — `pb_migrations/1784200000_created_push_subscriptions.js`, service helpers in `src/lib/pocketbase/services/push.ts`.
|
||||||
|
- **Opt-in settings toggle** — `src/features/settings/components/notifications-section.tsx` (per-device enable + "send test notification", via `src/hooks/use-web-push.ts`). Today it's one master switch; per-type preferences are a follow-up idea (see bottom).
|
||||||
|
- **SSE domain events** — `src/lib/events/emitter.ts` already types `match | reaction | tournament | team | player | badge` events, emitted from real product code paths (`src/features/matches/server.ts`, `src/features/tournaments/server.ts`, `src/features/badges/server.ts`, `src/features/players/server.ts`, `src/features/teams/server.ts`). **Most push triggers can hang off the exact same emit sites** — the moment we `emitServerEvent(...)`, we already know something notification-worthy happened.
|
||||||
|
|
||||||
|
Again: **no trigger below is wired.** The point of this board is to choose which few deserve to be.
|
||||||
|
|
||||||
|
## The menu
|
||||||
|
|
||||||
|
Columns: what fires it, who gets it, why it drives engagement, roughly how often it would fire, an annoyance-risk rating, and a suggested default (all of these are suggestions, not decisions). "Default ON" here means *on once the user enables notifications at all* — nothing fires for users who never flip the master switch.
|
||||||
|
|
||||||
|
### BEFORE the tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **New tournament posted / enrollment open** | Tournament created or opened for enrollment (`src/features/tournaments/server.ts` create emit, ~line 27) | All players | The starting gun — nobody wants to hear about a tournament secondhand | Rare (per tournament) | Low | Off (opt-in "announcements") |
|
||||||
|
| **Enrollment closing soon** | Timed job N hours before enrollment lock | Enrolled-nowhere players | FOMO nudge for fence-sitters | Once per tournament | **Med** — it's marketing, not information | Off |
|
||||||
|
| **You were claimed as a partner** | Free-agent partner assignment (`assignPartners` path in `src/features/tournaments/server.ts`, emits `team` + `tournament`, ~lines 385–397) | The claimed free agent(s) | Deeply personal — "someone picked YOU." Answers the question free agents are actively anxious about | Once per tournament per free agent | Low | **ON (candidate default)** |
|
||||||
|
| **Predictions are open** | Bracket generated / predictions window opens (`submitPrediction` lives in `src/features/predictions/server.ts`) | Enrolled players | Predictions only work if people fill them out before lock | Once per tournament | Low-med | Off |
|
||||||
|
| **Predictions closing soon** | Timed job before first-match lock (predictions lock on first match start) | Enrolled players who haven't submitted | Targeted (only non-submitters), genuinely useful deadline | ≤1 per tournament | Low-med | Off |
|
||||||
|
| **Side bets are open on a match** | Match hits `ready` (book opens per `docs/side-bets.md`) | Players with tokens, not in that match | Side-bets spec explicitly wants this nudge for marquee matches | Could be *every match* — needs throttling (e.g. finals only) | **High** if per-match; low if finals-only | Off |
|
||||||
|
| **Tournament starts in 1h** | Timed job vs. tournament start time | Enrolled players | Logistics — get people in the building | Once per tournament | Low | Off (but a strong opt-in) |
|
||||||
|
|
||||||
|
### DURING the tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **You're up next / your match started** | Admin starts the match — `startMatch` in `src/features/matches/server.ts` (~line 142, emits `match`) | The 2–4 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 3–6 per player per tournament | Low — this is the notification people *install the PWA for* | **ON (candidate default)** |
|
||||||
|
| **Score reported on your match — confirm needed** | `reportMatchScore` (~line 828) when the *other* team reports | The team that must confirm | Unblocks the bracket; the confirmer often doesn't know they're the bottleneck | ≤1 per match played | Low | Off, but arguably deserves default-on — it's a to-do, not a broadcast |
|
||||||
|
| **Your match result is final** | `finalizeMatch` (~line 753, single finalization path for admin `endMatch` + player `confirmMatchScore`) | Players in the match | Closure + "what's next" tap-through | 1 per match played | Low-med (you usually *know* — you were there) | Off |
|
||||||
|
| **You advanced / you were eliminated** | Bracket propagation after finalize (same `finalizeMatch` seam) | The advancing/eliminated team | "You're in the semis" is a brag-worthy moment; elimination push is a kindness debate | ≤1 per round | Med — elimination pushes can feel like salt | Off; if built, maybe advance-only |
|
||||||
|
| **Someone reacted to your match** | `toggleMatchReaction` (~line 964, emits `reaction`) | Players in the match | Social warm-fuzzies | Potentially **very** chatty | **High** — classic notification spam shape; needs heavy batching ("5 reactions on your match") if ever built | Off, probably forever |
|
||||||
|
| **Big upset just happened** | `finalizeMatch` + an upset heuristic (seed gap / prediction consensus) | Everyone at the tournament | Shared "did you SEE that" moment; drives people to the bracket | 0–2 per tournament if threshold is strict | Med — must be genuinely rare to land | Off |
|
||||||
|
| **Side-bet settled (you won/lost)** | Settlement inside `finalizeMatch` per `docs/side-bets.md` | Bettors on that match | Win reveals are dopamine; losses close the loop | Per bet placed (self-inflicted volume) | Low-med — user opted in by betting | Off (or implicit-on for people who bet?) |
|
||||||
|
|
||||||
|
### AFTER the tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Final results / you placed** | Tournament completed (tournament status emit in `src/features/tournaments/server.ts`) | All enrolled; podium gets a personal variant ("You took 2nd") | The recap moment; personal placement > generic results | Once per tournament | Low | Off (single post-tournament push is defensible as default though) |
|
||||||
|
| **You earned a badge** | `src/features/badges/server.ts` award (~line 44, emits `badge` with `playerId`) | The recipient | Personal, rare, purely positive — near-ideal push shape | Rare | Low | Off, but a strong candidate to promote later |
|
||||||
|
| **Prediction results — how you scored** | Final match finalizes → predictions settle | Players who submitted a prediction | Only reaches opt-ins-by-behavior; leaderboard tap-through | Once per tournament | Low | Off |
|
||||||
|
| **Side-bets final standings** | Tournament completes → side-bets leaderboard freezes | Players who placed ≥1 bet | Same self-selected audience as above | Once per tournament | Low | Off |
|
||||||
|
| **Your FLXN Replay is ready** | *(Future feature — no code yet.)* Post-tournament recap generation | All enrolled | Personalized recap = the highest-retention artifact we could push | Once per tournament | Low | Off until the feature exists; then a strong default candidate |
|
||||||
|
|
||||||
|
### AMBIENT / cross-tournament
|
||||||
|
|
||||||
|
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **A rival is in your next match** | *(Future feature — rivalries don't exist in code yet; head-to-head data does: `getMatchesBetweenPlayers` / `getMatchesBetweenTeams` in `src/features/matches/server.ts`)* | Both sides of the rivalry | "Revenge match" framing is the best narrative hook in the app | Rare | Low-med | Off until rivalries exist |
|
||||||
|
| **New tournament posted** | Tournament create emit (also listed under BEFORE — could be the same "announcements" type) | Everyone | Re-engages lapsed players between tournaments | Rare | Low | Off (opt-in "announcements") |
|
||||||
|
| **Free agents need partners** | N unclaimed free agents as enrollment deadline nears | Enrolled players without full teams | Matchmaking pressure in both directions | ≤1 per tournament | Med | Off |
|
||||||
|
| **You've been made admin / roster changes touch you** | `player` emit sites in `src/features/players/server.ts` | Affected player | Administrative courtesy | Very rare | Low | Off — probably not worth building |
|
||||||
|
|
||||||
|
## Suggested tiny default-on set (opinion, not decision)
|
||||||
|
|
||||||
|
If a user enables notifications at all, they get exactly two things until they say otherwise:
|
||||||
|
|
||||||
|
1. **You're up next** — `startMatch` seam.
|
||||||
|
2. **You were claimed as a partner** — free-agent assignment seam.
|
||||||
|
|
||||||
|
Everything else is opt-in per type. Both defaults are rare, personal, and actionable; neither can fire more than a handful of times per tournament. This matches the seams already name-dropped in the `sendPushToPlayer` doc comment.
|
||||||
|
|
||||||
|
## How to wire one (when the picks are made)
|
||||||
|
|
||||||
|
The pattern is deliberately boring:
|
||||||
|
|
||||||
|
1. Find the emit site — e.g. `startMatch` in `src/features/matches/server.ts` already calls `emitServerEvent({ type: "match", ... })` when the admin starts a match.
|
||||||
|
2. Next to that emit, resolve the affected player IDs and call `sendPushToPlayer(playerId, { title, body, url })` from `src/lib/push/index.ts`, with `url` deep-linking to the match/tournament route. Fire-and-forget (don't block the server fn on push transport), use `tag` to collapse repeats of the same kind.
|
||||||
|
3. That's it — subscription storage, multi-device fan-out, and dead-endpoint pruning are already handled by the foundation.
|
||||||
|
|
||||||
|
## Follow-up idea: per-type preferences
|
||||||
|
|
||||||
|
Today `src/features/settings/components/notifications-section.tsx` is a single per-device switch. Once types are chosen, extend that section with **category toggles** (e.g. "My matches", "Announcements", "Social", "Results & recaps") persisted per *player* (not per device), and have each trigger check the player's category preference before calling `sendPushToPlayer`. Categories, not individual types — a settings screen with 15 switches is its own kind of annoying.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "@lingui/conf";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
sourceLocale: "en",
|
||||||
|
locales: ["en", "es", "de", "ja"],
|
||||||
|
catalogs: [
|
||||||
|
{
|
||||||
|
path: "<rootDir>/src/locales/{locale}/messages",
|
||||||
|
include: ["src"],
|
||||||
|
exclude: ["**/node_modules/**", "**/*.d.ts"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
+10
-1
@@ -5,11 +5,15 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
"build": "vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
"extract": "lingui extract --clean",
|
||||||
|
"i18n:check": "lingui extract --clean && lingui compile --strict",
|
||||||
|
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
||||||
"start": "bun run server.ts"
|
"start": "bun run server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
|
"@lingui/core": "^6.5.0",
|
||||||
|
"@lingui/react": "^6.5.0",
|
||||||
"@mantine/carousel": "^8.2.4",
|
"@mantine/carousel": "^8.2.4",
|
||||||
"@mantine/core": "^8.2.4",
|
"@mantine/core": "^8.2.4",
|
||||||
"@mantine/dates": "^8.2.4",
|
"@mantine/dates": "^8.2.4",
|
||||||
@@ -43,12 +47,17 @@
|
|||||||
"supertokens-web-js": "^0.15.0",
|
"supertokens-web-js": "^0.15.0",
|
||||||
"twilio": "^5.8.0",
|
"twilio": "^5.8.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.0.15"
|
"zod": "^4.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lingui/babel-plugin-lingui-macro": "^6.5.0",
|
||||||
|
"@lingui/cli": "^6.5.0",
|
||||||
|
"@lingui/vite-plugin": "^6.5.0",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"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',
|
'icon-512x512.png',
|
||||||
'site.webmanifest',
|
'site.webmanifest',
|
||||||
'styles.css',
|
'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,
|
navigateFallback: null,
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
clientsClaim: 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 ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
||||||
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
||||||
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
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 ApiEventsSplatRouteImport } from './routes/api/events.$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||||
@@ -149,6 +152,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
|
|||||||
path: '/api/spotify/callback',
|
path: '/api/spotify/callback',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} 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({
|
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
||||||
id: '/api/events/$',
|
id: '/api/events/$',
|
||||||
path: '/api/events/$',
|
path: '/api/events/$',
|
||||||
@@ -275,6 +293,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatRoute
|
'/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/callback': typeof ApiSpotifyCallbackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -314,6 +335,9 @@ export interface FileRoutesByTo {
|
|||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatRoute
|
'/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/callback': typeof ApiSpotifyCallbackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -356,6 +380,9 @@ export interface FileRoutesById {
|
|||||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatRoute
|
'/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/callback': typeof ApiSpotifyCallbackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -398,6 +425,9 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/push/subscribe'
|
||||||
|
| '/api/push/test'
|
||||||
|
| '/api/push/unsubscribe'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
@@ -437,6 +467,9 @@ export interface FileRouteTypes {
|
|||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/push/subscribe'
|
||||||
|
| '/api/push/test'
|
||||||
|
| '/api/push/unsubscribe'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
@@ -478,6 +511,9 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/tournaments/$tournamentId'
|
| '/_authed/tournaments/$tournamentId'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
|
| '/api/push/subscribe'
|
||||||
|
| '/api/push/test'
|
||||||
|
| '/api/push/unsubscribe'
|
||||||
| '/api/spotify/callback'
|
| '/api/spotify/callback'
|
||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
@@ -509,6 +545,9 @@ export interface RootRouteChildren {
|
|||||||
ApiHealthRoute: typeof ApiHealthRoute
|
ApiHealthRoute: typeof ApiHealthRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
||||||
|
ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
|
||||||
|
ApiPushTestRoute: typeof ApiPushTestRoute
|
||||||
|
ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
|
||||||
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
||||||
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
||||||
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
||||||
@@ -662,6 +701,27 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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/$': {
|
'/api/events/$': {
|
||||||
id: '/api/events/$'
|
id: '/api/events/$'
|
||||||
path: '/api/events/$'
|
path: '/api/events/$'
|
||||||
@@ -874,6 +934,9 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiHealthRoute: ApiHealthRoute,
|
ApiHealthRoute: ApiHealthRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
||||||
|
ApiPushSubscribeRoute: ApiPushSubscribeRoute,
|
||||||
|
ApiPushTestRoute: ApiPushTestRoute,
|
||||||
|
ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
|
||||||
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
||||||
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
||||||
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
||||||
|
|||||||
+79
-68
@@ -18,6 +18,8 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
|||||||
import { HeaderConfig } from "@/features/core/types/header-config";
|
import { HeaderConfig } from "@/features/core/types/header-config";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n";
|
||||||
|
import { getRootMeta } from "@/lib/i18n/meta";
|
||||||
import FullScreenLoader from "@/components/full-screen-loader";
|
import FullScreenLoader from "@/components/full-screen-loader";
|
||||||
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
||||||
import mantineCssUrl from '@mantine/core/styles.css?url'
|
import mantineCssUrl from '@mantine/core/styles.css?url'
|
||||||
@@ -33,74 +35,81 @@ export const Route = createRootRouteWithContext<{
|
|||||||
withPadding: boolean;
|
withPadding: boolean;
|
||||||
fullWidth: boolean;
|
fullWidth: boolean;
|
||||||
}>()({
|
}>()({
|
||||||
head: () => ({
|
head: () => {
|
||||||
title: "FLXN IX",
|
// Meta stays DEFAULT_LOCALE: crawlers/unfurlers have no session, and
|
||||||
meta: [
|
// reading match.context here creates circular route-type inference that
|
||||||
{
|
// breaks beforeLoad typing in child routes.
|
||||||
charSet: "utf-8",
|
const rootMeta = getRootMeta(DEFAULT_LOCALE);
|
||||||
},
|
|
||||||
{
|
return {
|
||||||
name: "viewport",
|
title: "FLXN IX",
|
||||||
content:
|
meta: [
|
||||||
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
|
{
|
||||||
},
|
charSet: "utf-8",
|
||||||
{ name: 'description', content: 'Amicus meus madidus' },
|
},
|
||||||
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
{
|
||||||
{ property: 'og:title', content: 'FLXN' },
|
name: "viewport",
|
||||||
{ property: 'og:description', content: 'Amicus meus madidus' },
|
content:
|
||||||
{ property: 'og:url', content: 'https://flexxon.app' },
|
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
|
||||||
{ property: 'og:type', content: 'website' },
|
},
|
||||||
{ property: 'og:site_name', content: 'FLXN' },
|
{ name: 'description', content: rootMeta.description },
|
||||||
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
|
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
||||||
{ property: 'og:image:width', content: '512' },
|
{ property: 'og:title', content: 'FLXN' },
|
||||||
{ property: 'og:image:height', content: '512' },
|
{ property: 'og:description', content: rootMeta.description },
|
||||||
{ property: 'og:image:alt', content: 'FLXN logo' },
|
{ property: 'og:url', content: 'https://flexxon.app' },
|
||||||
{ property: 'og:locale', content: 'en_US' },
|
{ property: 'og:type', content: 'website' },
|
||||||
{ name: 'twitter:card', content: 'summary' },
|
{ property: 'og:site_name', content: 'FLXN' },
|
||||||
{ name: 'twitter:title', content: 'FLXN' },
|
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
|
||||||
{ name: 'twitter:description', content: 'Amicus meus madidus' },
|
{ property: 'og:image:width', content: '512' },
|
||||||
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
{ property: 'og:image:height', content: '512' },
|
||||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
{ property: 'og:image:alt', content: rootMeta.ogImageAlt },
|
||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
{ property: 'og:locale', content: rootMeta.ogLocale },
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
{ name: 'twitter:card', content: 'summary' },
|
||||||
{ name: 'apple-mobile-web-app-title', content: 'FLXN' },
|
{ name: 'twitter:title', content: 'FLXN' },
|
||||||
],
|
{ name: 'twitter:description', content: rootMeta.description },
|
||||||
links: [
|
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
||||||
{
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
rel: "apple-touch-icon",
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
sizes: "180x180",
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
||||||
href: "/apple-touch-icon.png",
|
{ name: 'apple-mobile-web-app-title', content: 'FLXN' },
|
||||||
},
|
],
|
||||||
{
|
links: [
|
||||||
rel: "icon",
|
{
|
||||||
type: "image/png",
|
rel: "apple-touch-icon",
|
||||||
sizes: "32x32",
|
sizes: "180x180",
|
||||||
href: "/favicon-32x32.png",
|
href: "/apple-touch-icon.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: "icon",
|
rel: "icon",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
sizes: "16x16",
|
sizes: "32x32",
|
||||||
href: "/favicon-16x16.png",
|
href: "/favicon-32x32.png",
|
||||||
},
|
},
|
||||||
{ rel: "manifest", href: "/site.webmanifest" },
|
{
|
||||||
{ rel: "icon", href: "/favicon.ico" },
|
rel: "icon",
|
||||||
{ rel: 'stylesheet', href: mantineCssUrl },
|
type: "image/png",
|
||||||
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
sizes: "16x16",
|
||||||
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
href: "/favicon-16x16.png",
|
||||||
{ rel: 'stylesheet', href: mantineTiptapCssUrl },
|
},
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: "manifest", href: "/site.webmanifest" },
|
||||||
{
|
{ rel: "icon", href: "/favicon.ico" },
|
||||||
rel: "preconnect",
|
{ rel: 'stylesheet', href: mantineCssUrl },
|
||||||
href: "https://fonts.gstatic.com",
|
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
||||||
crossOrigin: "anonymous",
|
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
||||||
},
|
{ rel: 'stylesheet', href: mantineTiptapCssUrl },
|
||||||
{
|
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||||
rel: "stylesheet",
|
{
|
||||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=League+Spartan:wght@100..900&display=swap",
|
rel: "preconnect",
|
||||||
}
|
href: "https://fonts.gstatic.com",
|
||||||
],
|
crossOrigin: "anonymous",
|
||||||
}),
|
},
|
||||||
|
{
|
||||||
|
rel: "stylesheet",
|
||||||
|
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=League+Spartan:wght@100..900&display=swap",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
errorComponent: (props) => {
|
errorComponent: (props) => {
|
||||||
return (
|
return (
|
||||||
<RootDocument>
|
<RootDocument>
|
||||||
@@ -123,6 +132,7 @@ export const Route = createRootRouteWithContext<{
|
|||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
|
await ensureMessages(resolveLocale(auth?.metadata?.locale));
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (isRedirect(error) || error instanceof Response) throw error;
|
if (isRedirect(error) || error instanceof Response) throw error;
|
||||||
@@ -138,6 +148,7 @@ export const Route = createRootRouteWithContext<{
|
|||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
|
await ensureMessages(resolveLocale(auth?.metadata?.locale));
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import type { HeaderConfig } from "@/features/core/types/header-config";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin")({
|
export const Route = createFileRoute("/_authed/admin")({
|
||||||
component: Outlet,
|
component: Outlet,
|
||||||
@@ -7,12 +9,12 @@ export const Route = createFileRoute("/_authed/admin")({
|
|||||||
throw redirect({ to: "/" });
|
throw redirect({ to: "/" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const header: HeaderConfig = {
|
||||||
header: {
|
...context.header,
|
||||||
...context.header,
|
title: msg`Admin`,
|
||||||
title: "Admin",
|
withBackButton: true,
|
||||||
withBackButton: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { header };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ActivitiesTable, activityQueries } from "@/features/activities";
|
|||||||
import { PlayersActivityTable, playerQueries } from "@/features/players";
|
import { PlayersActivityTable, playerQueries } from "@/features/players";
|
||||||
import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
|
import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/activities")({
|
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||||
component: Stats,
|
component: Stats,
|
||||||
@@ -16,7 +18,7 @@ export const Route = createFileRoute("/_authed/admin/activities")({
|
|||||||
withPadding: false,
|
withPadding: false,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
header: {
|
header: {
|
||||||
title: "Activities",
|
title: msg`Activities`,
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey],
|
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey],
|
||||||
@@ -53,8 +55,8 @@ function Stats() {
|
|||||||
return (
|
return (
|
||||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
<Tabs.List mb='md'>
|
<Tabs.List mb='md'>
|
||||||
<Tabs.Tab value="server-functions">Server Functions</Tabs.Tab>
|
<Tabs.Tab value="server-functions"><Trans>Server Functions</Trans></Tabs.Tab>
|
||||||
<Tabs.Tab value="player-activity">Player Activity</Tabs.Tab>
|
<Tabs.Tab value="player-activity"><Trans>Player Activity</Trans></Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="server-functions">
|
<Tabs.Panel value="server-functions">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { AdminPage } from "@/features/admin";
|
import { AdminPage } from "@/features/admin";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/")({
|
export const Route = createFileRoute("/_authed/admin/")({
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: "Admin",
|
title: msg`Admin`,
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import BracketPreview from "@/features/admin/components/preview";
|
|||||||
import { NumberInput } from "@mantine/core";
|
import { NumberInput } from "@mantine/core";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/preview")({
|
export const Route = createFileRoute("/_authed/admin/preview")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: "Bracket Preview",
|
title: msg`Bracket Preview`,
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
@@ -16,13 +18,14 @@ export const Route = createFileRoute("/_authed/admin/preview")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
const { t } = useLingui();
|
||||||
const [n, setN] = useState(16);
|
const [n, setN] = useState(16);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
min={9}
|
min={9}
|
||||||
max={27}
|
max={27}
|
||||||
label="Number of teams"
|
label={t`Number of teams`}
|
||||||
value={n}
|
value={n}
|
||||||
onChange={(value) => setN(value as number)}
|
onChange={(value) => setN(value as number)}
|
||||||
w={150}
|
w={150}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import TeamAssignmentPreview from "@/features/tournaments/components/team-assign
|
|||||||
import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||||
import { PlayerInfo } from "@/features/players/types";
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -23,7 +25,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-part
|
|||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `Manage ${context.tournament.name}`,
|
title: msg`Manage {name}`,
|
||||||
|
titleValues: { name: context.tournament.name },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -120,19 +123,19 @@ function RouteComponent() {
|
|||||||
{freeAgents.length}
|
{freeAgents.length}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{freeAgents.length === 1 ? "player enrolled" : "players enrolled"}
|
<Plural value={freeAgents.length} one="player enrolled" other="players enrolled" />
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!hasEnoughPlayers && (
|
{!hasEnoughPlayers && (
|
||||||
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
|
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
|
||||||
Need at least 2 players to create teams
|
<Trans>Need at least 2 players to create teams</Trans>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasOddPlayers && (
|
{hasOddPlayers && (
|
||||||
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
|
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
|
||||||
Cannot create teams with an odd number of players. Please have one player unenroll.
|
<Trans>Cannot create teams with an odd number of players. Please have one player unenroll.</Trans>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ function RouteComponent() {
|
|||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
loading={generateMutation.isPending}
|
loading={generateMutation.isPending}
|
||||||
>
|
>
|
||||||
Generate Random Pairings
|
<Trans>Generate Random Pairings</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -151,7 +154,7 @@ function RouteComponent() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text size="lg" fw={600}>
|
<Text size="lg" fw={600}>
|
||||||
Partner Assignments
|
<Trans>Partner Assignments</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Button
|
<Button
|
||||||
@@ -161,7 +164,7 @@ function RouteComponent() {
|
|||||||
loading={generateMutation.isPending}
|
loading={generateMutation.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Re-roll
|
<Trans>Re-roll</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<CheckCircleIcon size={18} />}
|
leftSection={<CheckCircleIcon size={18} />}
|
||||||
@@ -169,7 +172,7 @@ function RouteComponent() {
|
|||||||
loading={confirmMutation.isPending}
|
loading={confirmMutation.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Confirm & Create Teams
|
<Trans>Confirm & Create Teams</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
|
|||||||
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
|
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -19,7 +20,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
|||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `Manage ${context.tournament.name}`,
|
title: msg`Manage {name}`,
|
||||||
|
titleValues: { name: context.tournament.name },
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
|
|||||||
import ManageTeams from "@/features/teams/components/manage-teams";
|
import ManageTeams from "@/features/teams/components/manage-teams";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
|
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -19,7 +20,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
|||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `${context.tournament.name} Teams`,
|
title: msg`{name} Teams`,
|
||||||
|
titleValues: { name: context.tournament.name },
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
|||||||
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
|
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
@@ -13,7 +14,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
|||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: "Manage Tournaments",
|
title: msg`Manage Tournaments`,
|
||||||
},
|
},
|
||||||
refresh: tournamentQueries.list().queryKey,
|
refresh: tournamentQueries.list().queryKey,
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { Match } from "@/features/matches/types";
|
|||||||
import BracketView from "@/features/bracket/components/bracket-view";
|
import BracketView from "@/features/bracket/components/bracket-view";
|
||||||
import { SpotifyControlsBar } from "@/features/spotify/components";
|
import { SpotifyControlsBar } from "@/features/spotify/components";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -33,7 +35,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
|||||||
showSpotifyPanel: true,
|
showSpotifyPanel: true,
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `Run ${context.tournament.name}`,
|
title: msg`Run {name}`,
|
||||||
|
titleValues: { name: context.tournament.name },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -97,6 +100,16 @@ function RouteComponent() {
|
|||||||
) || false;
|
) || false;
|
||||||
}, [tournament.matches]);
|
}, [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(() => {
|
const bracket: BracketData = useMemo(() => {
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
return { winners: [], losers: [] };
|
return { winners: [], losers: [] };
|
||||||
@@ -146,11 +159,12 @@ function RouteComponent() {
|
|||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
groupConfig={tournament.group_config}
|
groupConfig={tournament.group_config}
|
||||||
|
nextUpMatchId={nextUpMatchId}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div>
|
<div>
|
||||||
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
<Title order={3} ta="center" mb="md"><Trans>Knockout Bracket</Trans></Title>
|
||||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : hasGroupStage ? (
|
) : hasGroupStage ? (
|
||||||
@@ -162,9 +176,10 @@ function RouteComponent() {
|
|||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
groupConfig={tournament.group_config}
|
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 ? (
|
tournament.regional === true ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import PlayerStatsTableSkeleton from '@/features/players/components/player-stats
|
|||||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/badges')({
|
export const Route = createFileRoute('/_authed/badges')({
|
||||||
component: Badges,
|
component: Badges,
|
||||||
@@ -16,7 +17,7 @@ export const Route = createFileRoute('/_authed/badges')({
|
|||||||
withPadding: false,
|
withPadding: false,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
header: {
|
header: {
|
||||||
title: 'All Badges',
|
title: msg`All Badges`,
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
refresh: [badgeQueries.allBadges().queryKey],
|
refresh: [badgeQueries.allBadges().queryKey],
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import { Box, Title, Stack } from "@mantine/core";
|
import { Box, Title, Stack } from "@mantine/core";
|
||||||
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
|
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
|
||||||
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
|
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
|
||||||
|
import LocalePicker from "@/features/settings/components/locale-picker";
|
||||||
|
import { NotificationsSection } from "@/features/settings/components/notifications-section";
|
||||||
import { SignOutIcon } from "@phosphor-icons/react";
|
import { SignOutIcon } from "@phosphor-icons/react";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/settings")({
|
export const Route = createFileRoute("/_authed/settings")({
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
title: "Settings",
|
title: msg`Settings`,
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
@@ -17,6 +21,7 @@ export const Route = createFileRoute("/_authed/settings")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
@@ -26,13 +31,15 @@ function RouteComponent() {
|
|||||||
borderBottom: "1px solid var(--mantine-color-default-border)",
|
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title order={3}>Appearance</Title>
|
<Title order={3}><Trans>Appearance</Trans></Title>
|
||||||
<Stack>
|
<Stack>
|
||||||
<AccentColorPicker />
|
<AccentColorPicker />
|
||||||
<ColorSchemePicker />
|
<ColorSchemePicker />
|
||||||
|
<LocalePicker />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
<NotificationsSection />
|
||||||
|
<ListLink label={t`Sign Out`} to="/logout" Icon={SignOutIcon} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||||
import { Suspense, useState, useDeferredValue } from "react";
|
import { Suspense, useState, useDeferredValue, useEffect } from "react";
|
||||||
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
||||||
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core";
|
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/stats")({
|
export const Route = createFileRoute("/_authed/stats")({
|
||||||
component: Stats,
|
component: Stats,
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
const queryClient = context.queryClient;
|
const queryClient = context.queryClient;
|
||||||
prefetchServerQuery(queryClient, playerQueries.allStats('all'));
|
prefetchServerQuery(queryClient, playerQueries.allStats('all'));
|
||||||
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
|
||||||
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
|
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
withPadding: false,
|
withPadding: false,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
header: {
|
header: {
|
||||||
title: "Player Stats"
|
title: msg`Player Stats`
|
||||||
},
|
},
|
||||||
refresh: [playerQueries.allStats().queryKey],
|
refresh: [playerQueries.allStats().queryKey],
|
||||||
}),
|
}),
|
||||||
@@ -29,12 +30,21 @@ function Stats() {
|
|||||||
const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
|
const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
|
||||||
const deferredViewType = useDeferredValue(viewType);
|
const deferredViewType = useDeferredValue(viewType);
|
||||||
const isStale = viewType !== deferredViewType;
|
const isStale = viewType !== deferredViewType;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
||||||
|
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
|
||||||
|
}, 1500);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="stats">
|
<Tabs defaultValue="stats">
|
||||||
<Tabs.List grow>
|
<Tabs.List grow>
|
||||||
<Tabs.Tab value="stats">Stats</Tabs.Tab>
|
<Tabs.Tab value="stats"><Trans>Stats</Trans></Tabs.Tab>
|
||||||
<Tabs.Tab value="h2h">Head to Head</Tabs.Tab>
|
<Tabs.Tab value="h2h"><Trans>Head to Head</Trans></Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="stats">
|
<Tabs.Panel value="stats">
|
||||||
@@ -46,21 +56,21 @@ function Stats() {
|
|||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
onClick={() => setViewType('all')}
|
onClick={() => setViewType('all')}
|
||||||
>
|
>
|
||||||
All
|
<Trans>All</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={viewType === 'mainline' ? 'filled' : 'light'}
|
variant={viewType === 'mainline' ? 'filled' : 'light'}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
onClick={() => setViewType('mainline')}
|
onClick={() => setViewType('mainline')}
|
||||||
>
|
>
|
||||||
Mainline
|
<Trans>Mainline</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={viewType === 'regional' ? 'filled' : 'light'}
|
variant={viewType === 'regional' ? 'filled' : 'light'}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
onClick={() => setViewType('regional')}
|
onClick={() => setViewType('regional')}
|
||||||
>
|
>
|
||||||
Regional
|
<Trans>Regional</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tournamentQueries, useTournament } from "@/features/tournaments/queries
|
|||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
import { Container } from "@mantine/core";
|
import { Container } from "@mantine/core";
|
||||||
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
|
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
|
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -19,7 +20,7 @@ export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
|
|||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: "Predictions",
|
title: msg`Predictions`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||||
@@ -41,7 +43,8 @@ export const Route = createFileRoute(
|
|||||||
withPadding: false,
|
withPadding: false,
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `${context.prediction.player.first_name}'s Bracket`,
|
title: msg`{name}'s Bracket`,
|
||||||
|
titleValues: { name: context.prediction.player.first_name },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -100,7 +103,7 @@ function RouteComponent() {
|
|||||||
<Group gap="md" wrap="nowrap">
|
<Group gap="md" wrap="nowrap">
|
||||||
<Stack gap={0} ta="center">
|
<Stack gap={0} ta="center">
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
PTS
|
<Trans>PTS</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={700}>
|
<Text size="sm" fw={700}>
|
||||||
{score.points}
|
{score.points}
|
||||||
@@ -108,7 +111,7 @@ function RouteComponent() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0} ta="center">
|
<Stack gap={0} ta="center">
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
PICKS
|
<Trans>PICKS</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{score.correct}/{score.total}
|
{score.correct}/{score.total}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
|
|||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
|
import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
|
||||||
import { Skeleton, Stack } from '@mantine/core'
|
import { Skeleton, Stack } from '@mantine/core'
|
||||||
|
import { msg } from '@lingui/core/macro'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/tournaments/')({
|
export const Route = createFileRoute('/_authed/tournaments/')({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
@@ -13,7 +14,7 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
|||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: 'Tournaments',
|
title: msg`Tournaments`,
|
||||||
},
|
},
|
||||||
refresh: tournamentQueries.list().queryKey
|
refresh: tournamentQueries.list().queryKey
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,41 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
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: msg`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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||||
|
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/spotify/capture")({
|
export const Route = createFileRoute("/api/spotify/capture")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||||
|
const i18n = localizedFor(null);
|
||||||
try {
|
try {
|
||||||
const cookies = request.headers.get("Cookie") || "";
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
const accessTokenMatch = cookies.match(
|
const accessTokenMatch = cookies.match(
|
||||||
@@ -14,7 +18,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
|
|||||||
|
|
||||||
if (!accessTokenMatch) {
|
if (!accessTokenMatch) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No access token found" }),
|
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -29,7 +33,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
|
|||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No active playback to capture" }),
|
JSON.stringify({ error: i18n._(msg`No active playback to capture`) }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -47,7 +51,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
|
|||||||
const errorMessage =
|
const errorMessage =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "Failed to capture playback state";
|
: i18n._(msg`Failed to capture playback state`);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: errorMessage }), {
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
|
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||||
|
|
||||||
function getAccessTokenFromCookies(request: Request): string | null {
|
function getAccessTokenFromCookies(request: Request): string | null {
|
||||||
const cookieHeader = request.headers.get("cookie");
|
const cookieHeader = request.headers.get("cookie");
|
||||||
@@ -16,11 +18,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||||
|
const i18n = localizedFor(null);
|
||||||
try {
|
try {
|
||||||
const accessToken = getAccessTokenFromCookies(request);
|
const accessToken = getAccessTokenFromCookies(request);
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No access token found" }),
|
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -41,7 +45,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
if (!trackId) {
|
if (!trackId) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "trackId is required for playTrack action",
|
error: i18n._(msg`trackId is required for playTrack action`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -63,7 +67,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
case "volume":
|
case "volume":
|
||||||
if (typeof volumePercent !== "number") {
|
if (typeof volumePercent !== "number") {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "volumePercent must be a number" }),
|
JSON.stringify({ error: i18n._(msg`volumePercent must be a number`) }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -76,7 +80,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "deviceId is required for transfer action",
|
error: i18n._(msg`deviceId is required for transfer action`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -87,10 +91,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
await spotifyClient.transferPlayback(deviceId);
|
await spotifyClient.transferPlayback(deviceId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
return new Response(
|
||||||
status: 400,
|
JSON.stringify({ error: i18n._(msg`Invalid action`) }),
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
});
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
@@ -104,8 +111,9 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
if (error.message.includes("NO_ACTIVE_DEVICE")) {
|
if (error.message.includes("NO_ACTIVE_DEVICE")) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error:
|
error: i18n._(
|
||||||
"No active device found. Please select a device first.",
|
msg`No active device found. Please select a device first.`
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -117,7 +125,9 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
if (error.message.includes("PREMIUM_REQUIRED")) {
|
if (error.message.includes("PREMIUM_REQUIRED")) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Spotify Premium is required for playback control.",
|
error: i18n._(
|
||||||
|
msg`Spotify Premium is required for playback control.`
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 403,
|
status: 403,
|
||||||
@@ -135,7 +145,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Playback control failed",
|
error: i18n._(msg`Playback control failed`),
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -147,11 +157,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
},
|
},
|
||||||
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
GET: async ({ request }: { request: Request }) => {
|
||||||
|
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||||
|
const i18n = localizedFor(null);
|
||||||
try {
|
try {
|
||||||
const accessToken = getAccessTokenFromCookies(request);
|
const accessToken = getAccessTokenFromCookies(request);
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No access token found" }),
|
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -190,7 +202,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get playback data error:", error);
|
console.error("Get playback data error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "Failed to get playback data" }),
|
JSON.stringify({ error: i18n._(msg`Failed to get playback data`) }),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||||
|
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/spotify/resume")({
|
export const Route = createFileRoute("/api/spotify/resume")({
|
||||||
server: {
|
server: {
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request }: { request: Request }) => {
|
POST: async ({ request }: { request: Request }) => {
|
||||||
|
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||||
|
const i18n = localizedFor(null);
|
||||||
try {
|
try {
|
||||||
const cookies = request.headers.get("Cookie") || "";
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
const accessTokenMatch = cookies.match(
|
const accessTokenMatch = cookies.match(
|
||||||
@@ -14,7 +18,7 @@ export const Route = createFileRoute("/api/spotify/resume")({
|
|||||||
|
|
||||||
if (!accessTokenMatch) {
|
if (!accessTokenMatch) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No access token found" }),
|
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -30,7 +34,7 @@ export const Route = createFileRoute("/api/spotify/resume")({
|
|||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: "No snapshot provided" }),
|
JSON.stringify({ error: i18n._(msg`No snapshot provided`) }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -47,14 +51,14 @@ export const Route = createFileRoute("/api/spotify/resume")({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Spotify resume error:", error);
|
console.error("Spotify resume error:", error);
|
||||||
|
|
||||||
let errorMessage = "Failed to resume playback state";
|
let errorMessage = i18n._(msg`Failed to resume playback state`);
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (
|
if (
|
||||||
error.message.includes("Premium") ||
|
error.message.includes("Premium") ||
|
||||||
error.message.includes("403")
|
error.message.includes("403")
|
||||||
) {
|
) {
|
||||||
errorMessage = "Spotify premium required";
|
errorMessage = i18n._(msg`Spotify premium required`);
|
||||||
} else {
|
} else {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
@@ -13,6 +15,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
|
const i18n = localizedFor(context.metadata);
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
@@ -27,7 +30,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Invalid input",
|
error: i18n._(msg`Invalid input`),
|
||||||
details: validationResult.error.issues,
|
details: validationResult.error.issues,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -40,7 +43,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
if (!logoFile || logoFile.size === 0) {
|
if (!logoFile || logoFile.size === 0) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Logo file is required",
|
error: i18n._(msg`Logo file is required`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -58,7 +61,9 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
if (!allowedTypes.includes(logoFile.type)) {
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
error: i18n._(
|
||||||
|
msg`Invalid file type. Only JPEG, PNG and GIF are allowed.`
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -71,7 +76,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
if (logoFile.size > maxSize) {
|
if (logoFile.size > maxSize) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "File too large. Maximum size is 10MB.",
|
error: i18n._(msg`File too large. Maximum size is 10MB.`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -84,7 +89,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
if (!team) {
|
if (!team) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Team not found",
|
error: i18n._(msg`Team not found`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -132,8 +137,8 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
|||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Failed to upload logo",
|
error: i18n._(msg`Failed to upload logo`),
|
||||||
message: error.message || "Unknown error occurred",
|
message: error.message || i18n._(msg`Unknown error occurred`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
@@ -13,6 +15,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
POST: async ({ request, context }) => {
|
POST: async ({ request, context }) => {
|
||||||
|
const i18n = localizedFor(context.metadata);
|
||||||
try {
|
try {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
@@ -28,7 +31,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Invalid input",
|
error: i18n._(msg`Invalid input`),
|
||||||
details: validationResult.error.issues,
|
details: validationResult.error.issues,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -41,7 +44,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
if (!logoFile || logoFile.size === 0) {
|
if (!logoFile || logoFile.size === 0) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Logo file is required",
|
error: i18n._(msg`Logo file is required`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -59,7 +62,9 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
if (!allowedTypes.includes(logoFile.type)) {
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
error: i18n._(
|
||||||
|
msg`Invalid file type. Only JPEG, PNG and GIF are allowed.`
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -72,7 +77,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
if (logoFile.size > maxSize) {
|
if (logoFile.size > maxSize) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "File too large. Maximum size is 10MB.",
|
error: i18n._(msg`File too large. Maximum size is 10MB.`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -85,7 +90,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
if (!tournament) {
|
if (!tournament) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Tournament not found",
|
error: i18n._(msg`Tournament not found`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -130,8 +135,8 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
|||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Failed to upload logo",
|
error: i18n._(msg`Failed to upload logo`),
|
||||||
message: error.message || "Unknown error occurred",
|
message: error.message || i18n._(msg`Unknown error occurred`),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|||||||
@@ -23,25 +23,27 @@ import { useEffect } from 'react'
|
|||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { logger } from '@/lib/logger'
|
import { logger } from '@/lib/logger'
|
||||||
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro'
|
||||||
import Button from './button'
|
import Button from './button'
|
||||||
|
|
||||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { t } = useLingui()
|
||||||
const isRoot = useMatch({
|
const isRoot = useMatch({
|
||||||
strict: false,
|
strict: false,
|
||||||
select: (state) => state.id === rootRouteId,
|
select: (state) => state.id === rootRouteId,
|
||||||
})
|
})
|
||||||
const [detailsOpened, { toggle: toggleDetails }] = useDisclosure(false)
|
const [detailsOpened, { toggle: toggleDetails }] = useDisclosure(false)
|
||||||
|
|
||||||
const errorMessage = error?.message || 'Unknown error'
|
const errorMessage = error?.message || t`Unknown error`
|
||||||
const errorStack = error?.stack || 'No stack trace available'
|
const errorStack = error?.stack || 'No stack trace available'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.error('DefaultCatchBoundary | ', error)
|
logger.error('DefaultCatchBoundary | ', error)
|
||||||
|
|
||||||
if (errorMessage.toLowerCase().includes('unauthenticated')) {
|
if (errorMessage.toLowerCase().includes('unauthenticated')) {
|
||||||
toast.error('You\'ve been logged out')
|
toast.error(t`You've been logged out`)
|
||||||
router.history.push('/login')
|
router.history.push('/login')
|
||||||
throw redirect({ to: '/login' })
|
throw redirect({ to: '/login' })
|
||||||
}
|
}
|
||||||
@@ -53,23 +55,23 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
<Center>
|
<Center>
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
|
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
|
||||||
<Text size="xl" fw={600}>Access Denied</Text>
|
<Text size="xl" fw={600}><Trans>Access Denied</Trans></Text>
|
||||||
<Text c="dimmed" ta="center">
|
<Text c="dimmed" ta="center">
|
||||||
You don't have permission to access this page.
|
<Trans>You don't have permission to access this page.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="sm" mt="md">
|
<Group gap="sm" mt="md">
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => window.history.back()}
|
onClick={() => window.history.back()}
|
||||||
>
|
>
|
||||||
Go Back
|
<Trans>Go Back</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<MantineButton
|
<MantineButton
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/"
|
to="/"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
Home
|
<Trans>Home</Trans>
|
||||||
</MantineButton>
|
</MantineButton>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -84,21 +86,21 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
<Stack align="center" gap="md" w="100%">
|
<Stack align="center" gap="md" w="100%">
|
||||||
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
|
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
|
||||||
|
|
||||||
<Text size="xl" fw={600}>Something went wrong</Text>
|
<Text size="xl" fw={600}><Trans>Something went wrong</Trans></Text>
|
||||||
|
|
||||||
<Text c="dimmed" ta="center">
|
<Text c="dimmed" ta="center">
|
||||||
An error occurred while loading this page.
|
<Trans>An error occurred while loading this page.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box w="100%" mt="md">
|
<Box w="100%" mt="md">
|
||||||
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
|
<Text size="sm" c="dimmed" mb="xs"><Trans>Error: {errorMessage}</Trans></Text>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={toggleDetails}
|
onClick={toggleDetails}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{detailsOpened ? 'Hide' : 'Show'} details
|
{detailsOpened ? <Trans>Hide details</Trans> : <Trans>Show details</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
<Collapse in={detailsOpened}>
|
<Collapse in={detailsOpened}>
|
||||||
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||||
@@ -112,7 +114,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => router.invalidate()}
|
onClick={() => router.invalidate()}
|
||||||
>
|
>
|
||||||
Retry
|
<Trans>Retry</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
{isRoot ? (
|
{isRoot ? (
|
||||||
<MantineButton
|
<MantineButton
|
||||||
@@ -120,14 +122,14 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
to="/"
|
to="/"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
>
|
>
|
||||||
Home
|
<Trans>Home</Trans>
|
||||||
</MantineButton>
|
</MantineButton>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onClick={() => window.history.back()}
|
onClick={() => window.history.back()}
|
||||||
>
|
>
|
||||||
Go Back
|
<Trans>Go Back</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { XIcon } from "@phosphor-icons/react";
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface AvatarProps
|
interface AvatarProps
|
||||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
@@ -30,6 +31,7 @@ const Avatar = ({
|
|||||||
contain = false,
|
contain = false,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||||
const hasImage = Boolean(props.src);
|
const hasImage = Boolean(props.src);
|
||||||
|
|
||||||
@@ -102,7 +104,7 @@ const Avatar = ({
|
|||||||
color="dark"
|
color="dark"
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
aria-label="Close image preview"
|
aria-label={t`Close image preview`}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -10,
|
top: -10,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import useNow from '@/hooks/use-now';
|
import useNow from '@/hooks/use-now';
|
||||||
import { Text, Group } from '@mantine/core';
|
import { Text, Group } from '@mantine/core';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import classes from './countdown.module.css';
|
import classes from './countdown.module.css';
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
@@ -34,6 +35,7 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
|
|||||||
const pad = (num: number) => num.toString().padStart(2, '0');
|
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||||
|
|
||||||
export function Countdown({ date, label, color }: CountdownProps) {
|
export function Countdown({ date, label, color }: CountdownProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
const now = useNow();
|
const now = useNow();
|
||||||
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
|
|||||||
|
|
||||||
const prefix =
|
const prefix =
|
||||||
timeLeft.days > 0
|
timeLeft.days > 0
|
||||||
? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
? `${t`${timeLeft.days}d`} ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
||||||
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
|
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DatePicker, TimeInput } from "@mantine/dates";
|
|||||||
import { ActionIcon, Stack } from "@mantine/core";
|
import { ActionIcon, Stack } from "@mantine/core";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { ClockIcon } from "@phosphor-icons/react";
|
import { ClockIcon } from "@phosphor-icons/react";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface DateTimePickerProps {
|
interface DateTimePickerProps {
|
||||||
value: Date | null;
|
value: Date | null;
|
||||||
@@ -16,6 +17,7 @@ const DateTimePicker = ({
|
|||||||
label,
|
label,
|
||||||
...rest
|
...rest
|
||||||
}: DateTimePickerProps) => {
|
}: DateTimePickerProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const timeRef = useRef<HTMLInputElement>(null);
|
const timeRef = useRef<HTMLInputElement>(null);
|
||||||
const currentDate = value ? new Date(value) : null;
|
const currentDate = value ? new Date(value) : null;
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ const DateTimePicker = ({
|
|||||||
/>
|
/>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
ref={timeRef}
|
ref={timeRef}
|
||||||
label="Time"
|
label={t`Time`}
|
||||||
size="md"
|
size="md"
|
||||||
value={formatTime(currentDate)}
|
value={formatTime(currentDate)}
|
||||||
onChange={handleTimeChange}
|
onChange={handleTimeChange}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
|
interface InfiniteScrollProps<T> {
|
||||||
|
items: T[];
|
||||||
|
renderItem: (item: T, index: number) => ReactNode;
|
||||||
|
batchSize?: number;
|
||||||
|
initialCount?: number;
|
||||||
|
rootMargin?: string;
|
||||||
|
loader?: ReactNode;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteScroll = <T,>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
batchSize = 25,
|
||||||
|
initialCount = batchSize,
|
||||||
|
rootMargin = "600px 0px",
|
||||||
|
loader,
|
||||||
|
hasMore = false,
|
||||||
|
loading = false,
|
||||||
|
onLoadMore,
|
||||||
|
}: InfiniteScrollProps<T>) => {
|
||||||
|
const [visibleCount, setVisibleCount] = useState(initialCount);
|
||||||
|
const [prevItems, setPrevItems] = useState(items);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
if (prevItems !== items) {
|
||||||
|
setPrevItems(items);
|
||||||
|
setVisibleCount(initialCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHiddenItems = visibleCount < items.length;
|
||||||
|
const showLoader = hasHiddenItems || hasMore || loading;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (!sentinel) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||||
|
|
||||||
|
if (visibleCount < items.length) {
|
||||||
|
setVisibleCount((count) => Math.min(count + batchSize, items.length));
|
||||||
|
} else if (hasMore && !loading) {
|
||||||
|
onLoadMore?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [items, visibleCount, batchSize, hasMore, loading, onLoadMore, rootMargin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.slice(0, visibleCount).map((item, index) => renderItem(item, index))}
|
||||||
|
{showLoader && (
|
||||||
|
<>
|
||||||
|
<Box ref={sentinelRef} />
|
||||||
|
{loader}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteScroll;
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
||||||
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro'
|
||||||
|
|
||||||
export function IOSInstallPrompt() {
|
export function IOSInstallPrompt() {
|
||||||
|
const { t } = useLingui()
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
||||||
|
|
||||||
@@ -31,8 +33,8 @@ export function IOSInstallPrompt() {
|
|||||||
if (!show || !platform) return null
|
if (!show || !platform) return null
|
||||||
|
|
||||||
const instructions = platform === 'ios'
|
const instructions = platform === 'ios'
|
||||||
? 'Tap Share → Add to Home Screen'
|
? t`Tap Share → Add to Home Screen`
|
||||||
: 'Tap Menu (⋮) → Add to Home screen'
|
: t`Tap Menu (⋮) → Add to Home screen`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
|
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
|
||||||
@@ -42,7 +44,7 @@ export function IOSInstallPrompt() {
|
|||||||
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
||||||
Please install FLXN • This will save me Twilio credits as you won't be signed out!
|
<Trans>Please install FLXN • This will save me Twilio credits as you won't be signed out!</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
||||||
{instructions}
|
{instructions}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Input, InputProps, Group, Text } from "@mantine/core";
|
import { Input, InputProps, Group, Text } from "@mantine/core";
|
||||||
import { CheckFat, Phone } from "@phosphor-icons/react";
|
import { CheckFat, Phone } from "@phosphor-icons/react";
|
||||||
import { IMaskInput } from "react-imask";
|
import { IMaskInput } from "react-imask";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface PhoneNumberInputProps extends InputProps {
|
interface PhoneNumberInputProps extends InputProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,6 +21,7 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
|
|||||||
error,
|
error,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
return (
|
return (
|
||||||
<Input.Wrapper
|
<Input.Wrapper
|
||||||
id={id}
|
id={id}
|
||||||
@@ -35,13 +37,13 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
|
|||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
<Phone size={20} /> {" "}
|
<Phone size={20} /> {" "}
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
+1
|
<Trans>+1</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
leftSectionWidth={50}
|
leftSectionWidth={50}
|
||||||
leftSectionProps={{ style: { padding: 0 } }}
|
leftSectionProps={{ style: { padding: 0 } }}
|
||||||
placeholder="(713) 867-5309"
|
placeholder={t`(713) 867-5309`}
|
||||||
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
|
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
|
||||||
rightSection={
|
rightSection={
|
||||||
value?.length === 10 && (
|
value?.length === 10 && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
|
import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
|
||||||
import { PropsWithChildren, Suspense } from "react";
|
import { PropsWithChildren, Suspense } from "react";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface ModalProps extends PropsWithChildren {
|
interface ModalProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -15,31 +16,35 @@ const Modal: React.FC<ModalProps> = ({
|
|||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
onExited,
|
onExited,
|
||||||
}) => (
|
}) => {
|
||||||
<MantineModal
|
const { t } = useLingui();
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
return (
|
||||||
title={<Title order={3}>{title}</Title>}
|
<MantineModal
|
||||||
radius={20}
|
opened={opened}
|
||||||
transitionProps={{
|
onClose={onClose}
|
||||||
transition: "pop",
|
title={<Title order={3}>{title}</Title>}
|
||||||
duration: 200,
|
radius={20}
|
||||||
timingFunction: "ease-out",
|
transitionProps={{
|
||||||
onExited,
|
transition: "pop",
|
||||||
}}
|
duration: 200,
|
||||||
overlayProps={{ backgroundOpacity: 0.4 }}
|
timingFunction: "ease-out",
|
||||||
closeButtonProps={{ "aria-label": "Close" }}
|
onExited,
|
||||||
>
|
}}
|
||||||
<Suspense
|
overlayProps={{ backgroundOpacity: 0.4 }}
|
||||||
fallback={
|
closeButtonProps={{ "aria-label": t`Close` }}
|
||||||
<Flex justify="center" align="center" w="100%" h={400}>
|
|
||||||
<Loader size="lg" />
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{children}
|
<Suspense
|
||||||
</Suspense>
|
fallback={
|
||||||
</MantineModal>
|
<Flex justify="center" align="center" w="100%" h={400}>
|
||||||
);
|
<Loader size="lg" />
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</MantineModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
|
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
|
||||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||||
import React, { ComponentType, useContext } from "react";
|
import React, { ComponentType, useContext } from "react";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { SlidePanelContext } from "./slide-panel-context";
|
import { SlidePanelContext } from "./slide-panel-context";
|
||||||
|
|
||||||
interface SlidePanelFieldProps {
|
interface SlidePanelFieldProps {
|
||||||
@@ -23,12 +24,13 @@ const SlidePanelField = ({
|
|||||||
Component,
|
Component,
|
||||||
title,
|
title,
|
||||||
label,
|
label,
|
||||||
placeholder = "Select value",
|
placeholder,
|
||||||
withAsterisk = false,
|
withAsterisk = false,
|
||||||
formatValue,
|
formatValue,
|
||||||
componentProps,
|
componentProps,
|
||||||
error,
|
error,
|
||||||
}: SlidePanelFieldProps) => {
|
}: SlidePanelFieldProps) => {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
const context = useContext(SlidePanelContext);
|
const context = useContext(SlidePanelContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -53,11 +55,11 @@ const SlidePanelField = ({
|
|||||||
}
|
}
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return value.toLocaleDateString();
|
return value.toLocaleDateString(i18n.locale);
|
||||||
}
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
return placeholder;
|
return placeholder ?? t`Select value`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||||
import { useState, ReactNode } from "react";
|
import { useState, ReactNode } from "react";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
||||||
import Button from "@/components/button";
|
import Button from "@/components/button";
|
||||||
|
|
||||||
@@ -28,13 +29,16 @@ const SlidePanel = ({
|
|||||||
children,
|
children,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
submitText = "Submit",
|
submitText,
|
||||||
cancelText = "Cancel",
|
cancelText,
|
||||||
cancelColor = "red",
|
cancelColor = "red",
|
||||||
maxHeight = "70vh",
|
maxHeight = "70vh",
|
||||||
formProps = {},
|
formProps = {},
|
||||||
loading = false,
|
loading = false,
|
||||||
}: SlidePanelProps) => {
|
}: SlidePanelProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const resolvedSubmitText = submitText ?? t`Submit`;
|
||||||
|
const resolvedCancelText = cancelText ?? t`Cancel`;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
||||||
const [tempValue, setTempValue] = useState<any>(null);
|
const [tempValue, setTempValue] = useState<any>(null);
|
||||||
@@ -113,7 +117,7 @@ const SlidePanel = ({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{submitText}
|
{resolvedSubmitText}
|
||||||
</Button>
|
</Button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button
|
||||||
@@ -124,7 +128,7 @@ const SlidePanel = ({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{cancelText}
|
{resolvedCancelText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -153,7 +157,7 @@ const SlidePanel = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
onClick={closePanel}
|
onClick={closePanel}
|
||||||
aria-label="Back"
|
aria-label={t`Back`}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon size={24} />
|
<ArrowLeftIcon size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -162,7 +166,7 @@ const SlidePanel = ({
|
|||||||
variant="transparent"
|
variant="transparent"
|
||||||
color="green"
|
color="green"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
aria-label="Confirm"
|
aria-label={t`Confirm`}
|
||||||
>
|
>
|
||||||
<CheckIcon size={24} />
|
<CheckIcon size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -182,8 +186,8 @@ const SlidePanel = ({
|
|||||||
/>
|
/>
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
<Stack mt="auto" w="100%" gap={2}>
|
<Stack mt="auto" w="100%" gap={2}>
|
||||||
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
|
<Button mt="md" onClick={handleConfirm}><Trans>Confirm</Trans></Button>
|
||||||
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
|
<Button variant="subtle" onClick={closePanel} mt="sm" color="red"><Trans>Cancel</Trans></Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { BaseStats } from "@/types/stats";
|
import { BaseStats } from "@/types/stats";
|
||||||
|
|
||||||
interface StatsOverviewProps {
|
interface StatsOverviewProps {
|
||||||
@@ -62,11 +63,13 @@ const StatItem = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
if (!statsData && !isLoading) {
|
if (!statsData && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<Box p="sm" h="auto" mih={200}>
|
<Box p="sm" h="auto" mih={200}>
|
||||||
<Text ta="center" size="sm" fw={600} c="dimmed">
|
<Text ta="center" size="sm" fw={600} c="dimmed">
|
||||||
No stats available yet
|
<Trans>No stats available yet</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -96,15 +99,15 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
|||||||
const avgMarginOfLoss = statsData.margin_of_loss ? parseFloat(statsData.margin_of_loss.toFixed(1)) : 0;
|
const avgMarginOfLoss = statsData.margin_of_loss ? parseFloat(statsData.margin_of_loss.toFixed(1)) : 0;
|
||||||
|
|
||||||
const allStats = [
|
const allStats = [
|
||||||
{ label: "Matches Played", value: overallStats.matches, Icon: BoxingGloveIcon },
|
{ label: t`Matches Played`, value: overallStats.matches, Icon: BoxingGloveIcon },
|
||||||
{ label: "Wins", value: overallStats.wins, Icon: CrownIcon },
|
{ label: t`Wins`, value: overallStats.wins, Icon: CrownIcon },
|
||||||
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
|
{ label: t`Losses`, value: overallStats.losses, Icon: XIcon },
|
||||||
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
|
{ label: t`Cups Made`, value: overallStats.total_cups_made, Icon: FireIcon },
|
||||||
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
|
{ label: t`Cups Against`, value: overallStats.total_cups_against, Icon: ShieldIcon },
|
||||||
{ label: "Avg Cups Per Match", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
{ label: t`Avg Cups Per Match`, value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
||||||
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
{ label: t`Avg Cups Against`, value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
||||||
{ label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
{ label: t`Avg Win Margin`, value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
||||||
{ label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
{ label: t`Avg Loss Margin`, value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,16 +130,17 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const StatsSkeleton = () => {
|
export const StatsSkeleton = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const skeletonStats = [
|
const skeletonStats = [
|
||||||
{ label: "Matches Played", Icon: BoxingGloveIcon },
|
{ label: t`Matches Played`, Icon: BoxingGloveIcon },
|
||||||
{ label: "Wins", Icon: CrownIcon },
|
{ label: t`Wins`, Icon: CrownIcon },
|
||||||
{ label: "Losses", Icon: XIcon },
|
{ label: t`Losses`, Icon: XIcon },
|
||||||
{ label: "Cups Made", Icon: FireIcon },
|
{ label: t`Cups Made`, Icon: FireIcon },
|
||||||
{ label: "Cups Against", Icon: ShieldIcon },
|
{ label: t`Cups Against`, Icon: ShieldIcon },
|
||||||
{ label: "Avg Cups Per Match", Icon: ChartLineUpIcon },
|
{ label: t`Avg Cups Per Match`, Icon: ChartLineUpIcon },
|
||||||
{ label: "Avg Cups Against", Icon: ShieldCheckIcon },
|
{ label: t`Avg Cups Against`, Icon: ShieldCheckIcon },
|
||||||
{ label: "Avg Win Margin", Icon: ArrowUpIcon },
|
{ label: t`Avg Win Margin`, Icon: ArrowUpIcon },
|
||||||
{ label: "Avg Loss Margin", Icon: ArrowDownIcon },
|
{ label: t`Avg Loss Margin`, Icon: ArrowDownIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import { useRouter } from "@tanstack/react-router";
|
|||||||
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Stable URL slug for the ?tab= param; defaults to label. Set this when label is translated. */
|
||||||
|
value?: string;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabValue = (tab: TabItem) => (tab.value ?? tab.label).toLowerCase();
|
||||||
|
|
||||||
interface SwipeableTabsProps {
|
interface SwipeableTabsProps {
|
||||||
tabs: TabItem[];
|
tabs: TabItem[];
|
||||||
defaultTab?: number;
|
defaultTab?: number;
|
||||||
@@ -36,7 +40,7 @@ function SwipeableTabs({
|
|||||||
const urlTab = search?.tab;
|
const urlTab = search?.tab;
|
||||||
if (typeof urlTab === "string") {
|
if (typeof urlTab === "string") {
|
||||||
const tabIndex = tabs.findIndex(
|
const tabIndex = tabs.findIndex(
|
||||||
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
|
(tab) => tabValue(tab) === urlTab.toLowerCase()
|
||||||
);
|
);
|
||||||
return tabIndex !== -1 ? tabIndex : defaultTab;
|
return tabIndex !== -1 ? tabIndex : defaultTab;
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,7 @@ function SwipeableTabs({
|
|||||||
?.querySelector(".mantine-ScrollArea-viewport")
|
?.querySelector(".mantine-ScrollArea-viewport")
|
||||||
?.scrollTo({ top: 0 });
|
?.scrollTo({ top: 0 });
|
||||||
|
|
||||||
const tabLabel = tabs[index].label.toLowerCase();
|
const tabLabel = tabValue(tabs[index]);
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("tab", tabLabel);
|
url.searchParams.set("tab", tabLabel);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect, ReactNode } from "react";
|
import { useState, useRef, useEffect, ReactNode } from "react";
|
||||||
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
|
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
export interface TypeaheadOption<T = any> {
|
export interface TypeaheadOption<T = any> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,12 +25,14 @@ const Typeahead = <T,>({
|
|||||||
searchFn,
|
searchFn,
|
||||||
renderOption,
|
renderOption,
|
||||||
format,
|
format,
|
||||||
placeholder = "Search...",
|
placeholder,
|
||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
initialValue = "",
|
initialValue = "",
|
||||||
maxHeight = 200,
|
maxHeight = 200,
|
||||||
}: TypeaheadProps<T>) => {
|
}: TypeaheadProps<T>) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const resolvedPlaceholder = placeholder ?? t`Search...`;
|
||||||
const [searchQuery, setSearchQuery] = useState(initialValue);
|
const [searchQuery, setSearchQuery] = useState(initialValue);
|
||||||
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -119,7 +122,7 @@ const Typeahead = <T,>({
|
|||||||
}
|
}
|
||||||
await performSearch(searchQuery);
|
await performSearch(searchQuery);
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={resolvedPlaceholder}
|
||||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +167,7 @@ const Typeahead = <T,>({
|
|||||||
) : (
|
) : (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
|
{searchQuery.trim() ? t`No results found` : t`Start typing to search...`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { playerKeys, playerQueries, useMe } from "@/features/players/queries";
|
|||||||
|
|
||||||
interface AuthData {
|
interface AuthData {
|
||||||
user: Player | undefined;
|
user: Player | undefined;
|
||||||
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
|
metadata: {
|
||||||
|
accentColor: MantineColor;
|
||||||
|
colorScheme: MantineColorScheme;
|
||||||
|
locale?: string;
|
||||||
|
};
|
||||||
roles: string[];
|
roles: string[];
|
||||||
phone: string;
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
|
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { SpotifyAuth } from '@/lib/spotify/auth';
|
import { SpotifyAuth } from '@/lib/spotify/auth';
|
||||||
import { useAuth } from './auth-context';
|
import { useAuth } from './auth-context';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
@@ -38,49 +39,51 @@ const deepEqual = (a: unknown, b: unknown): boolean => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
|
||||||
const response = await fetch(`/api/spotify/${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = 'Request failed';
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
errorMessage = errorData.error || errorMessage;
|
|
||||||
} catch {
|
|
||||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
if (!contentType.includes('application/json')) {
|
|
||||||
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const { roles } = useAuth();
|
const { roles } = useAuth();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
// Defined inside the provider so error messages can be localized via `t`.
|
||||||
|
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||||
|
const response = await fetch(`/api/spotify/${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = t`Request failed`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = t`HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [authState, setAuthState] = useState<SpotifyAuthState>(defaultSpotifyState);
|
const [authState, setAuthState] = useState<SpotifyAuthState>(defaultSpotifyState);
|
||||||
|
|
||||||
@@ -150,10 +153,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
let errorMessage = `Authentication failed: ${error}`;
|
const errorMessage = details
|
||||||
if (details) {
|
? t`Authentication failed: ${error} - ${decodeURIComponent(details)}`
|
||||||
errorMessage += ` - ${decodeURIComponent(details)}`;
|
: t`Authentication failed: ${error}`;
|
||||||
}
|
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
|
|
||||||
console.error('Spotify OAuth Error:', { error, details });
|
console.error('Spotify OAuth Error:', { error, details });
|
||||||
@@ -203,7 +205,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
|
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -226,7 +228,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const pause = useCallback(async () => {
|
const pause = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -249,7 +251,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const skipNext = useCallback(async () => {
|
const skipNext = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -272,7 +274,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const skipPrevious = useCallback(async () => {
|
const skipPrevious = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -295,7 +297,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const setVolume = useCallback(async (volumePercent: number) => {
|
const setVolume = useCallback(async (volumePercent: number) => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -309,11 +311,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
body: JSON.stringify({ action: 'volume', volumePercent }),
|
body: JSON.stringify({ action: 'volume', volumePercent }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Failed to set volume');
|
setError(error instanceof Error ? error.message : t`Failed to set volume`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const getDevices = useCallback(async () => {
|
const getDevices = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -330,11 +332,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
setActiveDeviceState(active);
|
setActiveDeviceState(active);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Failed to get devices');
|
setError(error instanceof Error ? error.message : t`Failed to get devices`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const setActiveDevice = useCallback(async (deviceId: string) => {
|
const setActiveDevice = useCallback(async (deviceId: string) => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -355,11 +357,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
|
|
||||||
setTimeout(getDevices, 1000);
|
setTimeout(getDevices, 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Failed to set active device');
|
setError(error instanceof Error ? error.message : t`Failed to set active device`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated, devices]);
|
}, [authState.isAuthenticated, devices, t]);
|
||||||
|
|
||||||
const refreshPlaybackState = useCallback(async () => {
|
const refreshPlaybackState = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -382,7 +384,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to refresh playback state:', error);
|
console.warn('Failed to refresh playback state:', error);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
@@ -422,11 +424,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
setCapturedState(response.snapshot);
|
setCapturedState(response.snapshot);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Failed to capture playback state');
|
setError(error instanceof Error ? error.message : t`Failed to capture playback state`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCaptureLoading(false);
|
setIsCaptureLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [authState.isAuthenticated, t]);
|
||||||
|
|
||||||
const resumePlaybackState = useCallback(async () => {
|
const resumePlaybackState = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated || !capturedState) return;
|
if (!authState.isAuthenticated || !capturedState) return;
|
||||||
@@ -442,11 +444,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
|
|
||||||
setTimeout(refreshPlaybackState, 1000);
|
setTimeout(refreshPlaybackState, 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error instanceof Error ? error.message : 'Failed to resume playback state');
|
setError(error instanceof Error ? error.message : t`Failed to resume playback state`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsResumeLoading(false);
|
setIsResumeLoading(false);
|
||||||
}
|
}
|
||||||
}, [authState.isAuthenticated, capturedState, refreshPlaybackState]);
|
}, [authState.isAuthenticated, capturedState, refreshPlaybackState, t]);
|
||||||
|
|
||||||
const clearCapturedState = useCallback(() => {
|
const clearCapturedState = useCallback(() => {
|
||||||
setCapturedState(null);
|
setCapturedState(null);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, memo } from "react";
|
import { useState, useMemo, memo } from "react";
|
||||||
|
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -35,13 +36,14 @@ interface ActivityListItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
const playerName = typeof activity.player === "object" && activity.player
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
: "System";
|
: t`System`;
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString(i18n.locale);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +80,7 @@ const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) =>
|
|||||||
{playerName}
|
{playerName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{activity.duration}ms
|
<Trans>{activity.duration}ms</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{formatDate(activity.created)}
|
{formatDate(activity.created)}
|
||||||
@@ -102,44 +104,45 @@ interface ActivityDetailsSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
if (!activity) return null;
|
if (!activity) return null;
|
||||||
|
|
||||||
const playerName = typeof activity.player === "object" && activity.player
|
const playerName = typeof activity.player === "object" && activity.player
|
||||||
? `${activity.player.first_name} ${activity.player.last_name}`
|
? `${activity.player.first_name} ${activity.player.last_name}`
|
||||||
: "System";
|
: t`System`;
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString(i18n.locale);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet title="Activity Details" opened={isOpen} onChange={onClose}>
|
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
|
||||||
<Stack gap="md" p="md">
|
<Stack gap="md" p="md">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
Function Name
|
<Trans>Function Name</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">{activity.name}</Text>
|
<Text size="sm">{activity.name}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
Status
|
<Trans>Status</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{activity.success ? (
|
{activity.success ? (
|
||||||
<>
|
<>
|
||||||
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
|
||||||
<Text size="sm" c="green">
|
<Text size="sm" c="green">
|
||||||
Success
|
<Trans>Success</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
<XIcon size={16} color="var(--mantine-color-red-6)" />
|
||||||
<Text size="sm" c="red">
|
<Text size="sm" c="red">
|
||||||
Failed
|
<Trans>Failed</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -148,21 +151,21 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
|
|||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
Player
|
<Trans>Player</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">{playerName}</Text>
|
<Text size="sm">{playerName}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
Duration
|
<Trans>Duration</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">{activity.duration}ms</Text>
|
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
Created
|
<Trans>Created</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">{formatDate(activity.created)}</Text>
|
<Text size="sm">{formatDate(activity.created)}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -170,7 +173,7 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
|
|||||||
{activity.user_agent && (
|
{activity.user_agent && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
User Agent
|
<Trans>User Agent</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
<Text size="xs" style={{ wordBreak: "break-word" }}>
|
||||||
{activity.user_agent}
|
{activity.user_agent}
|
||||||
@@ -181,7 +184,7 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
|
|||||||
{activity.error && (
|
{activity.error && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
Error Message
|
<Trans>Error Message</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Alert color="red" variant="light">
|
<Alert color="red" variant="light">
|
||||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||||
@@ -194,7 +197,7 @@ const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetail
|
|||||||
{activity.arguments && (
|
{activity.arguments && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} c="dimmed">
|
<Text size="xs" fw={700} c="dimmed">
|
||||||
Arguments
|
<Trans>Arguments</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Code block style={{ fontSize: "11px" }}>
|
<Code block style={{ fontSize: "11px" }}>
|
||||||
{JSON.stringify(activity.arguments, null, 2)}
|
{JSON.stringify(activity.arguments, null, 2)}
|
||||||
@@ -228,7 +231,7 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
|
|||||||
<PulseIcon size={32} />
|
<PulseIcon size={32} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Title order={3} c="dimmed">
|
<Title order={3} c="dimmed">
|
||||||
No Activities Found
|
<Trans>No Activities Found</Trans>
|
||||||
</Title>
|
</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -248,6 +251,7 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ActivitiesTable = () => {
|
export const ActivitiesTable = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
const [successFilter, setSuccessFilter] = useState<string | null>(null);
|
||||||
@@ -302,7 +306,7 @@ export const ActivitiesTable = () => {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Stack gap="xs" px="md">
|
<Stack gap="xs" px="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="serverFn name"
|
placeholder={t`serverFn name`}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.currentTarget.value);
|
setSearch(e.currentTarget.value);
|
||||||
@@ -314,16 +318,16 @@ export const ActivitiesTable = () => {
|
|||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Status"
|
placeholder={t`Status`}
|
||||||
value={successFilter}
|
value={successFilter}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSuccessFilter(value);
|
setSuccessFilter(value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
data={[
|
data={[
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: t`All` },
|
||||||
{ value: "success", label: "Success" },
|
{ value: "success", label: t`Success` },
|
||||||
{ value: "failure", label: "Failure" },
|
{ value: "failure", label: t`Failure` },
|
||||||
]}
|
]}
|
||||||
clearable
|
clearable
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -334,11 +338,11 @@ export const ActivitiesTable = () => {
|
|||||||
|
|
||||||
<Group px="md" justify="space-between" align="center">
|
<Group px="md" justify="space-between" align="center">
|
||||||
<Text size="10px" lh={0} c="dimmed">
|
<Text size="10px" lh={0} c="dimmed">
|
||||||
{result.totalItems} total activities
|
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Sort:
|
<Trans>Sort:</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => handleSort("created")}
|
onClick={() => handleSort("created")}
|
||||||
@@ -349,7 +353,7 @@ export const ActivitiesTable = () => {
|
|||||||
fw={sortBy.includes("created") ? 600 : 400}
|
fw={sortBy.includes("created") ? 600 : 400}
|
||||||
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
|
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
|
||||||
>
|
>
|
||||||
Date
|
<Trans>Date</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{getSortIcon("created")}
|
{getSortIcon("created")}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@@ -365,7 +369,7 @@ export const ActivitiesTable = () => {
|
|||||||
fw={sortBy.includes("duration") ? 600 : 400}
|
fw={sortBy.includes("duration") ? 600 : 400}
|
||||||
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
|
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
|
||||||
>
|
>
|
||||||
Duration
|
<Trans>Duration</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{getSortIcon("duration")}
|
{getSortIcon("duration")}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { List } from "@mantine/core";
|
import { List } from "@mantine/core";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import {
|
import {
|
||||||
DatabaseIcon,
|
DatabaseIcon,
|
||||||
@@ -13,6 +14,7 @@ import { migrateBadgeProgress } from "@/features/badges/server";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const [isMigrating, setIsMigrating] = useState(false);
|
const [isMigrating, setIsMigrating] = useState(false);
|
||||||
|
|
||||||
const handleMigrateBadges = async () => {
|
const handleMigrateBadges = async () => {
|
||||||
@@ -26,35 +28,35 @@ const AdminPage = () => {
|
|||||||
return (
|
return (
|
||||||
<List p="0">
|
<List p="0">
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Manage Tournaments"
|
label={t`Manage Tournaments`}
|
||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
to="/admin/tournaments"
|
to="/admin/tournaments"
|
||||||
/>
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Award Badges"
|
label={t`Award Badges`}
|
||||||
Icon={CrownIcon}
|
Icon={CrownIcon}
|
||||||
to="/admin/badges"
|
to="/admin/badges"
|
||||||
/>
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Migrate Badge Progress"
|
label={t`Migrate Badge Progress`}
|
||||||
Icon={MedalIcon}
|
Icon={MedalIcon}
|
||||||
onClick={handleMigrateBadges}
|
onClick={handleMigrateBadges}
|
||||||
loading={isMigrating}
|
loading={isMigrating}
|
||||||
/>
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Activities"
|
label={t`Activities`}
|
||||||
Icon={ListIcon}
|
Icon={ListIcon}
|
||||||
to="/admin/activities"
|
to="/admin/activities"
|
||||||
/>
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Open Pocketbase"
|
label={t`Open Pocketbase`}
|
||||||
Icon={DatabaseIcon}
|
Icon={DatabaseIcon}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
|
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Bracket Preview"
|
label={t`Bracket Preview`}
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
to="/admin/preview"
|
to="/admin/preview"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { awardManualBadge } from "@/features/badges/server";
|
import { awardManualBadge } from "@/features/badges/server";
|
||||||
import { useAllBadges } from "@/features/badges/queries";
|
import { useAllBadges } from "@/features/badges/queries";
|
||||||
import toast from "@/lib/sonner";
|
import toast from "@/lib/sonner";
|
||||||
import { usePlayers } from "@/features/players/queries";
|
import { usePlayers } from "@/features/players/queries";
|
||||||
|
|
||||||
const AwardBadges = () => {
|
const AwardBadges = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const { data: players } = usePlayers();
|
const { data: players } = usePlayers();
|
||||||
const { data: allBadges } = useAllBadges();
|
const { data: allBadges } = useAllBadges();
|
||||||
|
|
||||||
@@ -30,13 +32,13 @@ const AwardBadges = () => {
|
|||||||
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
||||||
const playerName = selectedPlayer
|
const playerName = selectedPlayer
|
||||||
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
||||||
: "Player";
|
: t`Player`;
|
||||||
|
|
||||||
toast.success(`Badge awarded to ${playerName}`);
|
toast.success(t`Badge awarded to ${playerName}`);
|
||||||
|
|
||||||
setSelectedPlayerId(null);
|
setSelectedPlayerId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to award badge");
|
toast.error(t`Failed to award badge`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsAwarding(false);
|
setIsAwarding(false);
|
||||||
}
|
}
|
||||||
@@ -60,13 +62,13 @@ const AwardBadges = () => {
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="lg" fw={600} mb="xs">
|
<Text size="lg" fw={600} mb="xs">
|
||||||
Award Manual Badge
|
<Trans>Award Manual Badge</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Badge Type"
|
label={t`Badge Type`}
|
||||||
placeholder="Select a badge"
|
placeholder={t`Select a badge`}
|
||||||
data={badgeOptions}
|
data={badgeOptions}
|
||||||
value={selectedBadgeId}
|
value={selectedBadgeId}
|
||||||
onChange={setSelectedBadgeId}
|
onChange={setSelectedBadgeId}
|
||||||
@@ -81,8 +83,8 @@ const AwardBadges = () => {
|
|||||||
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Select
|
<Select
|
||||||
label="Select Player"
|
label={t`Select Player`}
|
||||||
placeholder="Choose a player"
|
placeholder={t`Choose a player`}
|
||||||
data={playerOptions}
|
data={playerOptions}
|
||||||
value={selectedPlayerId}
|
value={selectedPlayerId}
|
||||||
onChange={setSelectedPlayerId}
|
onChange={setSelectedPlayerId}
|
||||||
@@ -99,7 +101,7 @@ const AwardBadges = () => {
|
|||||||
loading={isAwarding}
|
loading={isAwarding}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
Award Badge
|
<Trans>Award Badge</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { Badge, BadgeProgress } from "../types";
|
import { Badge, BadgeProgress } from "../types";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
|
import { MedalIcon, LockKeyIcon } from "@phosphor-icons/react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface BadgeShowcaseProps {
|
interface BadgeShowcaseProps {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
@@ -297,7 +298,7 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
|||||||
<Box>
|
<Box>
|
||||||
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box mb="xs" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Text size="sm" fw={500} c="dimmed">
|
<Text size="sm" fw={500} c="dimmed">
|
||||||
Progress
|
<Trans>Progress </Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600} c="dimmed">
|
<Text size="sm" fw={600} c="dimmed">
|
||||||
{display.progressText}
|
{display.progressText}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Sheet from '@/components/sheet/sheet';
|
|||||||
import PlayerList from '@/features/players/components/player-list';
|
import PlayerList from '@/features/players/components/player-list';
|
||||||
import { useAuth } from '@/contexts/auth-context';
|
import { useAuth } from '@/contexts/auth-context';
|
||||||
import { Player } from '@/features/players/types';
|
import { Player } from '@/features/players/types';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
|
||||||
const BadgeStatsTable = () => {
|
const BadgeStatsTable = () => {
|
||||||
const { data: allBadges } = useAllBadges();
|
const { data: allBadges } = useAllBadges();
|
||||||
@@ -41,7 +42,7 @@ const BadgeStatsTable = () => {
|
|||||||
<Container px={0} size='md'>
|
<Container px={0} size='md'>
|
||||||
<Stack align='center' gap='md' py='xl'>
|
<Stack align='center' gap='md' py='xl'>
|
||||||
<Title order={3} c='dimmed'>
|
<Title order={3} c='dimmed'>
|
||||||
No Badges Available
|
<Trans>No Badges Available</Trans>
|
||||||
</Title>
|
</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -83,9 +84,10 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const badgeSheet = useSheet();
|
const badgeSheet = useSheet();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const playerNamesBlurb = useMemo(() => {
|
const playerNamesBlurb = useMemo(() => {
|
||||||
if (earnedBadges.length === 0) return 'No players yet';
|
if (earnedBadges.length === 0) return t`No players yet`;
|
||||||
|
|
||||||
const currentUserHasBadge = earnedBadges.some(
|
const currentUserHasBadge = earnedBadges.some(
|
||||||
(eb) => eb.player.id === user?.id
|
(eb) => eb.player.id === user?.id
|
||||||
@@ -100,27 +102,34 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
|
|||||||
: earnedBadges.slice(0, 3);
|
: earnedBadges.slice(0, 3);
|
||||||
|
|
||||||
const names = displayPlayers.map((eb) => eb.player.first_name);
|
const names = displayPlayers.map((eb) => eb.player.first_name);
|
||||||
|
const namesList = names.join(', ');
|
||||||
|
|
||||||
if (currentUserHasBadge) {
|
if (currentUserHasBadge) {
|
||||||
const remaining = earnedBadges.length - 1 - names.length;
|
const remaining = earnedBadges.length - 1 - names.length;
|
||||||
if (names.length === 0 && remaining === 0) {
|
if (names.length === 0 && remaining === 0) {
|
||||||
return 'You';
|
return t`You`;
|
||||||
} else if (names.length === 0 && remaining > 0) {
|
} else if (names.length === 0 && remaining > 0) {
|
||||||
return `You and ${remaining} other${remaining > 1 ? 's' : ''}`;
|
return remaining > 1
|
||||||
|
? t`You and ${remaining} others`
|
||||||
|
: t`You and 1 other`;
|
||||||
} else if (remaining > 0) {
|
} else if (remaining > 0) {
|
||||||
return `You, ${names.join(', ')} and ${remaining} other${remaining > 1 ? 's' : ''}`;
|
return remaining > 1
|
||||||
|
? t`You, ${namesList} and ${remaining} others`
|
||||||
|
: t`You, ${namesList} and 1 other`;
|
||||||
} else {
|
} else {
|
||||||
return `You${names.length > 0 ? ` and ${names.join(', ')}` : ''}`;
|
return names.length > 0 ? t`You and ${namesList}` : t`You`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const remaining = earnedBadges.length - names.length;
|
const remaining = earnedBadges.length - names.length;
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
return `${names.join(', ')} and ${remaining} other${remaining > 1 ? 's' : ''}`;
|
return remaining > 1
|
||||||
|
? t`${namesList} and ${remaining} others`
|
||||||
|
: t`${namesList} and 1 other`;
|
||||||
} else {
|
} else {
|
||||||
return names.join(', ');
|
return namesList;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [earnedBadges, user?.id]);
|
}, [earnedBadges, user?.id, t]);
|
||||||
|
|
||||||
const playersForList: Player[] = useMemo(() => {
|
const playersForList: Player[] = useMemo(() => {
|
||||||
return earnedBadges.map((eb) => ({
|
return earnedBadges.map((eb) => ({
|
||||||
@@ -163,12 +172,12 @@ const BadgeStatRow: React.FC<BadgeStatRowProps> = ({
|
|||||||
).toFixed(0)}
|
).toFixed(0)}
|
||||||
%
|
%
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size='xs'>of players</Text>
|
<Text c="dimmed" size='xs'><Trans>of players</Trans></Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Sheet title={badge.name + ' Badge Holders'} {...badgeSheet.props}>
|
<Sheet title={t`${badge.name} Badge Holders`} {...badgeSheet.props}>
|
||||||
<PlayerList players={playersForList} />
|
<PlayerList players={playersForList} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
{!isLastRow && <Divider />}
|
{!isLastRow && <Divider />}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import MatchDock from "./match-dock";
|
|||||||
import useAppShellHeight from "@/hooks/use-appshell-height";
|
import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface BracketViewProps {
|
interface BracketViewProps {
|
||||||
bracket: BracketData;
|
bracket: BracketData;
|
||||||
@@ -16,9 +17,10 @@ interface BracketViewProps {
|
|||||||
};
|
};
|
||||||
renderMatch?: (match: Match) => React.ReactNode;
|
renderMatch?: (match: Match) => React.ReactNode;
|
||||||
bottomOffset?: number;
|
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 height = useAppShellHeight();
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const hasAutoScrolled = useRef(false);
|
const hasAutoScrolled = useRef(false);
|
||||||
@@ -50,9 +52,10 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
|
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
|
||||||
(match) => !match.bye
|
(match) => !match.bye
|
||||||
);
|
);
|
||||||
const target =
|
const target = nextUpMatchId !== undefined
|
||||||
matches.find((match) => match.status === "started") ??
|
? matches.find((match) => match.id === nextUpMatchId)
|
||||||
matches.find((match) => match.status === "ready");
|
: matches.find((match) => match.status === "started") ??
|
||||||
|
matches.find((match) => match.status === "ready");
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
const viewport = viewportRef.current;
|
const viewport = viewportRef.current;
|
||||||
@@ -78,7 +81,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
? "auto"
|
? "auto"
|
||||||
: "smooth",
|
: "smooth",
|
||||||
});
|
});
|
||||||
}, [bracket]);
|
}, [bracket, nextUpMatchId]);
|
||||||
|
|
||||||
return <Box pos="relative">
|
return <Box pos="relative">
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
@@ -93,16 +96,16 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Winners Bracket
|
<Trans>Winners Bracket</Trans>
|
||||||
</Text>
|
</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>
|
</div>
|
||||||
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Losers Bracket
|
<Trans>Losers Bracket</Trans>
|
||||||
</Text>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
|
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface BracketProps {
|
|||||||
renderMatch?: (match: Match) => React.ReactNode;
|
renderMatch?: (match: Match) => React.ReactNode;
|
||||||
onMatchTap?: (match: Match) => void;
|
onMatchTap?: (match: Match) => void;
|
||||||
selectedMatchLid?: number | null;
|
selectedMatchLid?: number | null;
|
||||||
|
nextUpMatchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Bracket: React.FC<BracketProps> = ({
|
export const Bracket: React.FC<BracketProps> = ({
|
||||||
@@ -24,6 +25,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
renderMatch,
|
renderMatch,
|
||||||
onMatchTap,
|
onMatchTap,
|
||||||
selectedMatchLid,
|
selectedMatchLid,
|
||||||
|
nextUpMatchId,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
@@ -149,6 +151,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
groupConfig={groupConfig}
|
groupConfig={groupConfig}
|
||||||
onTap={onMatchTap}
|
onTap={onMatchTap}
|
||||||
selected={selectedMatchLid === match.lid}
|
selected={selectedMatchLid === match.lid}
|
||||||
|
nextUpMatchId={nextUpMatchId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { MatchSlot } from "./match-slot";
|
import { MatchSlot } from "./match-slot";
|
||||||
@@ -6,6 +6,8 @@ import { Match } from "@/features/matches/types";
|
|||||||
import { Team } from "@/features/teams/types";
|
import { Team } from "@/features/teams/types";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { MatchForm } from "./match-form";
|
import { MatchForm } from "./match-form";
|
||||||
|
import { MatchReport } from "./match-report";
|
||||||
|
import toast from "@/lib/sonner";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
import { endMatch, startMatch } from "@/features/matches/server";
|
import { endMatch, startMatch } from "@/features/matches/server";
|
||||||
@@ -14,6 +16,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
||||||
import { getGroupLabel } from "../utils/group-label";
|
import { getGroupLabel } from "../utils/group-label";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -25,6 +28,7 @@ interface MatchCardProps {
|
|||||||
};
|
};
|
||||||
onTap?: (match: Match) => void;
|
onTap?: (match: Match) => void;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
nextUpMatchId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||||
@@ -34,13 +38,17 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
groupConfig,
|
groupConfig,
|
||||||
onTap,
|
onTap,
|
||||||
selected,
|
selected,
|
||||||
|
nextUpMatchId,
|
||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const editSheet = useSheet();
|
const editSheet = useSheet();
|
||||||
const { playTrack, pause } = useSpotifyPlayback();
|
const { playTrack, pause } = useSpotifyPlayback();
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
const canTap = !!(onTap && match.home && match.away);
|
const canTap = !!(onTap && match.home && match.away);
|
||||||
|
|
||||||
|
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
|
||||||
|
|
||||||
const homeSlot = useMemo(
|
const homeSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
from: orders[match.home_from_lid],
|
from: orders[match.home_from_lid],
|
||||||
@@ -53,9 +61,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups > match.away_cups,
|
match.home_cups > match.away_cups,
|
||||||
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed, groupConfig) : undefined,
|
groupLabel: !match.home && match.home_seed ? getGroupLabel(i18n, match.home_seed, groupConfig) : undefined,
|
||||||
}),
|
}),
|
||||||
[match, orders, groupConfig]
|
[match, orders, groupConfig, i18n]
|
||||||
);
|
);
|
||||||
const awaySlot = useMemo(
|
const awaySlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -69,9 +77,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups > match.home_cups,
|
match.away_cups > match.home_cups,
|
||||||
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed, groupConfig) : undefined,
|
groupLabel: !match.away && match.away_seed ? getGroupLabel(i18n, match.away_seed, groupConfig) : undefined,
|
||||||
}),
|
}),
|
||||||
[match, orders, groupConfig]
|
[match, orders, groupConfig, i18n]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showToolbar = useMemo(
|
const showToolbar = useMemo(
|
||||||
@@ -80,7 +88,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showEditButton = useMemo(
|
const showEditButton = useMemo(
|
||||||
() => showControls && match.status === "started",
|
() =>
|
||||||
|
showControls && (match.status === "started" || match.status === "ended"),
|
||||||
[showControls, match.status]
|
[showControls, match.status]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,7 +97,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
|
|
||||||
const start = useServerMutation({
|
const start = useServerMutation({
|
||||||
mutationFn: startMatch,
|
mutationFn: startMatch,
|
||||||
successMessage: "Match started!",
|
successMessage: t`Match started!`,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: tournamentKeys.details(match.tournament.id),
|
queryKey: tournamentKeys.details(match.tournament.id),
|
||||||
@@ -98,11 +107,21 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
|
|
||||||
const end = useServerMutation({
|
const end = useServerMutation({
|
||||||
mutationFn: endMatch,
|
mutationFn: endMatch,
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: tournamentKeys.details(match.tournament.id),
|
queryKey: tournamentKeys.details(match.tournament.id),
|
||||||
});
|
});
|
||||||
editSheet.close();
|
editSheet.close();
|
||||||
|
if (data?.downstreamReset) {
|
||||||
|
toast.error(
|
||||||
|
t`Downstream matches were reset because the correction changed who advances.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data?.groupEditAfterKnockout) {
|
||||||
|
toast.error(
|
||||||
|
t`Group result changed after the bracket was seeded — reseed the knockout stage if needed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,7 +188,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
|
|
||||||
const handleSpeakerClick = useCallback(async () => {
|
const handleSpeakerClick = useCallback(async () => {
|
||||||
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
|
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
|
||||||
await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`);
|
await speak(t`${match.home?.name || t({ message: "Home", context: "match team" })} vs. ${match.away?.name || t({ message: "Away", context: "match team" })}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,14 +198,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
|
|
||||||
await playTeamWalkout(homeTeam);
|
await playTeamWalkout(homeTeam);
|
||||||
await speak(homeTeam.name);
|
await speak(homeTeam.name);
|
||||||
await speak("versus");
|
await speak(t`versus`);
|
||||||
await playTeamWalkout(awayTeam);
|
await playTeamWalkout(awayTeam);
|
||||||
await speak(awayTeam.name);
|
await speak(awayTeam.name);
|
||||||
await speak("have fun, good luck!");
|
await speak(t`have fun, good luck!`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Walkout sequence error:', error);
|
console.warn('Walkout sequence error:', error);
|
||||||
await speak(`${match.home.name} vs. ${match.away.name}`);
|
await speak(t`${match.home.name} vs. ${match.away.name}`);
|
||||||
}
|
}
|
||||||
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
|
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
|
||||||
|
|
||||||
@@ -195,10 +214,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
data: match.id,
|
data: match.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip announcements for regional tournaments
|
|
||||||
const isRegional = match.tournament?.regional === true;
|
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) {
|
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
|
||||||
try {
|
try {
|
||||||
const homeTeam = match.home as Team;
|
const homeTeam = match.home as Team;
|
||||||
@@ -206,10 +223,10 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
|
|
||||||
await playTeamWalkout(homeTeam);
|
await playTeamWalkout(homeTeam);
|
||||||
await speak(homeTeam.name);
|
await speak(homeTeam.name);
|
||||||
await speak("versus");
|
await speak(t`versus`);
|
||||||
await playTeamWalkout(awayTeam);
|
await playTeamWalkout(awayTeam);
|
||||||
await speak(awayTeam.name);
|
await speak(awayTeam.name);
|
||||||
await speak("have fun, good luck!");
|
await speak(t`have fun, good luck!`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Auto-walkout sequence error:', error);
|
console.warn('Auto-walkout sequence error:', error);
|
||||||
}
|
}
|
||||||
@@ -217,152 +234,168 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
|
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
<Stack gap={6} align="flex-end">
|
||||||
<Text
|
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||||
c="dimmed"
|
<Text
|
||||||
fw="bolder"
|
c="dimmed"
|
||||||
px={6}
|
fw="bolder"
|
||||||
py={2}
|
px={6}
|
||||||
style={{
|
py={2}
|
||||||
backgroundColor: 'var(--mantine-color-body)'
|
style={{
|
||||||
}}
|
backgroundColor: 'var(--mantine-color-body)'
|
||||||
>
|
}}
|
||||||
{match.order}
|
|
||||||
</Text>
|
|
||||||
<Flex align="stretch">
|
|
||||||
<Indicator
|
|
||||||
inline
|
|
||||||
processing={match.status === "started"}
|
|
||||||
color="red"
|
|
||||||
size={12}
|
|
||||||
disabled={match.status !== "started" || showEditButton}
|
|
||||||
>
|
>
|
||||||
<Card
|
{match.order}
|
||||||
w={showToolbar || showEditButton ? 200 : 220}
|
</Text>
|
||||||
withBorder
|
<Flex
|
||||||
pos="relative"
|
align="stretch"
|
||||||
className={canTap ? styles["tappable-card"] : undefined}
|
style={{
|
||||||
onClick={canTap ? () => onTap!(match) : undefined}
|
borderRadius: "var(--mantine-radius-default)",
|
||||||
role={canTap ? "button" : undefined}
|
boxShadow: isNextUp
|
||||||
tabIndex={canTap ? 0 : undefined}
|
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
|
||||||
onKeyDown={
|
: undefined,
|
||||||
canTap
|
transition:
|
||||||
? (e: React.KeyboardEvent) => {
|
"box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
}}
|
||||||
e.preventDefault();
|
>
|
||||||
onTap!(match);
|
<Indicator
|
||||||
}
|
inline
|
||||||
}
|
processing={match.status === "started"}
|
||||||
: undefined
|
color="red"
|
||||||
}
|
size={12}
|
||||||
aria-label={
|
disabled={match.status !== "started" || showEditButton}
|
||||||
canTap
|
|
||||||
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
overflow: "visible",
|
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
|
||||||
borderColor: selected
|
|
||||||
? 'var(--mantine-primary-color-filled)'
|
|
||||||
: 'var(--mantine-color-default-border)',
|
|
||||||
boxShadow: 'var(--mantine-shadow-sm)',
|
|
||||||
}}
|
|
||||||
data-match-lid={match.lid}
|
|
||||||
>
|
>
|
||||||
<Card.Section withBorder p={0}>
|
<Card
|
||||||
<MatchSlot {...homeSlot} />
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
</Card.Section>
|
withBorder
|
||||||
|
pos="relative"
|
||||||
|
className={canTap ? styles["tappable-card"] : undefined}
|
||||||
|
onClick={canTap ? () => onTap!(match) : undefined}
|
||||||
|
role={canTap ? "button" : undefined}
|
||||||
|
tabIndex={canTap ? 0 : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canTap
|
||||||
|
? (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onTap!(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
canTap
|
||||||
|
? t`Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderColor: selected
|
||||||
|
? '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}
|
||||||
|
>
|
||||||
|
<Card.Section withBorder p={0}>
|
||||||
|
<MatchSlot {...homeSlot} />
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
<Card.Section p={0} mb={-16}>
|
<Card.Section p={0} mb={-16}>
|
||||||
<MatchSlot {...awaySlot} />
|
<MatchSlot {...awaySlot} />
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
|
|
||||||
{match.reset && (
|
{match.reset && (
|
||||||
<Text
|
<Text
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
top={-20}
|
top={-20}
|
||||||
left={8}
|
left={8}
|
||||||
size="xs"
|
size="xs"
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
fw="bold"
|
fw="bold"
|
||||||
>
|
>
|
||||||
* If necessary
|
<Trans>* If necessary</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showControls && match.status !== "tbd" && (
|
{showControls && match.status !== "tbd" && (
|
||||||
|
<ActionIcon
|
||||||
|
pos="absolute"
|
||||||
|
bottom={-2}
|
||||||
|
left={-26}
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSpeakerClick();
|
||||||
|
}}
|
||||||
|
aria-label={t`Announce matchup`}
|
||||||
|
>
|
||||||
|
<SpeakerHighIcon size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Indicator>
|
||||||
|
|
||||||
|
{showToolbar && (
|
||||||
|
<Flex direction="column" justify="center" align="center">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
pos="absolute"
|
color="green"
|
||||||
bottom={-2}
|
onClick={handleStart}
|
||||||
left={-26}
|
loading={start.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="subtle"
|
h="100%"
|
||||||
color="gray"
|
radius="sm"
|
||||||
onClick={(e) => {
|
ml={-4}
|
||||||
e.stopPropagation();
|
aria-label={t`Start match`}
|
||||||
handleSpeakerClick();
|
style={{
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
}}
|
}}
|
||||||
aria-label="Announce matchup"
|
|
||||||
>
|
>
|
||||||
<SpeakerHighIcon size={12} />
|
<PlayIcon size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
</Flex>
|
||||||
</Card>
|
)}
|
||||||
</Indicator>
|
|
||||||
|
|
||||||
{showToolbar && (
|
|
||||||
<Flex direction="column" justify="center" align="center">
|
|
||||||
<ActionIcon
|
|
||||||
color="green"
|
|
||||||
onClick={handleStart}
|
|
||||||
loading={start.isPending}
|
|
||||||
size="sm"
|
|
||||||
h="100%"
|
|
||||||
radius="sm"
|
|
||||||
ml={-4}
|
|
||||||
aria-label="Start match"
|
|
||||||
style={{
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlayIcon size={14} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{showEditButton && (
|
{showEditButton && (
|
||||||
<Flex direction="column" justify="center" align="center">
|
<Flex direction="column" justify="center" align="center">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
color="blue"
|
color={match.status === "ended" ? "gray" : "blue"}
|
||||||
onClick={editSheet.open}
|
onClick={editSheet.open}
|
||||||
loading={end.isPending}
|
loading={end.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
h="100%"
|
h="100%"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
ml={-4}
|
ml={-4}
|
||||||
aria-label="Edit match score"
|
aria-label={t`Edit match score`}
|
||||||
style={{
|
style={{
|
||||||
borderTopLeftRadius: 0,
|
borderTopLeftRadius: 0,
|
||||||
borderBottomLeftRadius: 0,
|
borderBottomLeftRadius: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PencilIcon size={14} />
|
<PencilIcon size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Sheet title={t`Edit Match`} {...editSheet.props}>
|
||||||
|
<MatchForm
|
||||||
|
match={match}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
onCancel={editSheet.close}
|
||||||
|
loading={end.isPending}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Sheet title="Edit Match" {...editSheet.props}>
|
{!showControls && <MatchReport match={match} compact />}
|
||||||
<MatchForm
|
</Stack>
|
||||||
match={match}
|
|
||||||
onSubmit={handleFormSubmit}
|
|
||||||
onCancel={editSheet.close}
|
|
||||||
loading={end.isPending}
|
|
||||||
/>
|
|
||||||
</Sheet>
|
|
||||||
</Flex>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import TeamAvatar from "@/components/team-avatar";
|
|||||||
import AnimatedScore from "@/features/matches/components/animated-score";
|
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||||
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||||
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
|
||||||
|
import { MatchReport } from "./match-report";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||||
|
|
||||||
@@ -80,6 +82,7 @@ const TeamRow = ({
|
|||||||
const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
const h2hSheet = useSheet();
|
const h2hSheet = useSheet();
|
||||||
|
const { t } = useLingui();
|
||||||
const hasPrivate = match?.home?.private || match?.away?.private;
|
const hasPrivate = match?.home?.private || match?.away?.private;
|
||||||
const ended = match?.status === "ended";
|
const ended = match?.status === "ended";
|
||||||
const started = match?.status === "started";
|
const started = match?.status === "started";
|
||||||
@@ -117,14 +120,17 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
|
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
|
||||||
Match {match.order} · Round {match.round + 1}
|
{match.is_losers_bracket ? (
|
||||||
{match.is_losers_bracket && " (Losers)"}
|
<Trans>Match {match.order} · Round {match.round + 1} (Losers)</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Match {match.order} · Round {match.round + 1}</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<CloseButton
|
<CloseButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close match actions"
|
aria-label={t`Close match actions`}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -149,6 +155,8 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
|||||||
ended={ended}
|
ended={ended}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MatchReport match={match} />
|
||||||
|
|
||||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Suspense
|
<Suspense
|
||||||
@@ -162,12 +170,12 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
{!hasPrivate && (
|
{!hasPrivate && (
|
||||||
<Tooltip label="Head to Head" withArrow position="top">
|
<Tooltip label={t`Head to Head`} withArrow position="top">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={h2hSheet.open}
|
onClick={h2hSheet.open}
|
||||||
aria-label="View head-to-head"
|
aria-label={t`View head-to-head`}
|
||||||
w={40}
|
w={40}
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
@@ -204,7 +212,7 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{match?.home && match?.away && h2hSheet.isOpen && (
|
{match?.home && match?.away && h2hSheet.isOpen && (
|
||||||
<Sheet title="Head to Head" {...h2hSheet.props}>
|
<Sheet title={t`Head to Head`} {...h2hSheet.props}>
|
||||||
<TeamHeadToHeadSheet
|
<TeamHeadToHeadSheet
|
||||||
team1={match.home}
|
team1={match.home}
|
||||||
team2={match.away}
|
team2={match.away}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Button, TextInput, Stack, Group, Text, Flex, Divider, NumberInput } from "@mantine/core";
|
import { Button, TextInput, Stack, Group, Text, Flex, Divider, NumberInput } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface MatchFormProps {
|
interface MatchFormProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -19,6 +20,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
loading = false,
|
loading = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
home_cups: match.home_cups || 10,
|
home_cups: match.home_cups || 10,
|
||||||
@@ -27,42 +29,42 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
home_cups: (value, values) => {
|
home_cups: (value, values) => {
|
||||||
if (value === null || value === undefined) return "Home cups is required";
|
if (value === null || value === undefined) return t`Home cups is required`;
|
||||||
if (values.ot_count > 0) return null;
|
if (values.ot_count > 0) return null;
|
||||||
|
|
||||||
const homeCups = Number(value);
|
const homeCups = Number(value);
|
||||||
const awayCups = Number(values.away_cups);
|
const awayCups = Number(values.away_cups);
|
||||||
|
|
||||||
if (homeCups !== 10 && awayCups !== 10) {
|
if (homeCups !== 10 && awayCups !== 10) {
|
||||||
return "At least one team must have 10 cups";
|
return t`At least one team must have 10 cups`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (homeCups === 10 && awayCups === 10) {
|
if (homeCups === 10 && awayCups === 10) {
|
||||||
return "Both teams cannot have 10 cups";
|
return t`Both teams cannot have 10 cups`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
away_cups: (value, values) => {
|
away_cups: (value, values) => {
|
||||||
if (value === null || value === undefined) return "Away cups is required";
|
if (value === null || value === undefined) return t`Away cups is required`;
|
||||||
if (values.ot_count > 0) return null;
|
if (values.ot_count > 0) return null;
|
||||||
|
|
||||||
const awayCups = Number(value);
|
const awayCups = Number(value);
|
||||||
const homeCups = Number(values.home_cups);
|
const homeCups = Number(values.home_cups);
|
||||||
|
|
||||||
if (homeCups !== 10 && awayCups !== 10) {
|
if (homeCups !== 10 && awayCups !== 10) {
|
||||||
return "At least one team must have 10 cups";
|
return t`At least one team must have 10 cups`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (homeCups === 10 && awayCups === 10) {
|
if (homeCups === 10 && awayCups === 10) {
|
||||||
return "Both teams cannot have 10 cups";
|
return t`Both teams cannot have 10 cups`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
ot_count: (value) =>
|
ot_count: (value) =>
|
||||||
value === null || value === undefined
|
value === null || value === undefined
|
||||||
? "Overtime count is required"
|
? t`Overtime count is required`
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
transformValues: (values) => ({
|
transformValues: (values) => ({
|
||||||
@@ -85,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={500} size="sm">
|
<Text fw={500} size="sm">
|
||||||
{match.home?.name} Cups
|
<Trans>{match.home?.name} Cups</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
@@ -108,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={500} size="sm">
|
<Text fw={500} size="sm">
|
||||||
{match.away?.name} Cups
|
<Trans>{match.away?.name} Cups</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
@@ -130,7 +132,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
|
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text fw={500} size="sm">
|
<Text fw={500} size="sm">
|
||||||
OT Count
|
<Trans>OT Count</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
ml='auto'
|
ml='auto'
|
||||||
@@ -147,7 +149,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
|
|
||||||
<Stack mt="md">
|
<Stack mt="md">
|
||||||
<Button type="submit" loading={loading}>
|
<Button type="submit" loading={loading}>
|
||||||
Update Match
|
<Trans>Update Match</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -155,7 +157,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
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";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
|
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 { t } = useLingui();
|
||||||
|
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(t`Score confirmed 🍻`);
|
||||||
|
if (data.downstreamReset) {
|
||||||
|
toast.error(
|
||||||
|
t`Downstream matches were reset because the correction changed who advances.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.groupEditAfterKnockout) {
|
||||||
|
toast.error(
|
||||||
|
t`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(
|
||||||
|
t`Downstream matches were reset because the correction changed who advances.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data?.groupEditAfterKnockout) {
|
||||||
|
toast.error(
|
||||||
|
t`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(t`Score submitted — waiting for the other team`);
|
||||||
|
},
|
||||||
|
[report, match.id, formSheet.close, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitConfirm = useCallback(() => {
|
||||||
|
confirm.mutate({ data: { matchId: match.id } });
|
||||||
|
formSheet.close();
|
||||||
|
toast.success(t`Score confirmed 🍻`);
|
||||||
|
}, [confirm, match.id, formSheet.close, t]);
|
||||||
|
|
||||||
|
const submitClear = useCallback(() => {
|
||||||
|
clear.mutate({ data: { matchId: match.id } });
|
||||||
|
toast.success(t`Report cleared`);
|
||||||
|
}, [clear, match.id, t]);
|
||||||
|
|
||||||
|
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 ? t`Confirm Score` : t`Report Score`}
|
||||||
|
{...formSheet.props}
|
||||||
|
>
|
||||||
|
{sheetTakenOver ? (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
icon={<LightningIcon size={18} weight="fill" />}
|
||||||
|
title={t`${teamName(reportingTeam) ?? t`The other team`} just reported a score`}
|
||||||
|
>
|
||||||
|
<Trans>Does this look right?</Trans>
|
||||||
|
</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) ?? t({ message: "Home", context: "match team" })}
|
||||||
|
</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) ?? t({ message: "Away", context: "match team" })}
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
<Trans>Confirm {scoreText}</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="default"
|
||||||
|
onClick={dismissTakeover}
|
||||||
|
disabled={confirm.isPending}
|
||||||
|
>
|
||||||
|
<Trans>Not right — enter our score</Trans>
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
<Trans>Report Score</Trans>
|
||||||
|
</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 }}>
|
||||||
|
<Trans>
|
||||||
|
Waiting for {teamName(opposingTeam) ?? t`the other team`} to confirm{" "}
|
||||||
|
·{" "}
|
||||||
|
<Text span fw={600} c="bright">
|
||||||
|
{scoreText}
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={submitClear}
|
||||||
|
loading={clear.isPending}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isParticipant) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||||
|
<Trans>
|
||||||
|
{teamName(reportingTeam) ?? t`Opponent`} reported{" "}
|
||||||
|
<Text span fw={600} c="bright">
|
||||||
|
{scoreText}
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
leftSection={<CheckIcon size={12} weight="bold" />}
|
||||||
|
onClick={submitConfirm}
|
||||||
|
loading={confirm.isPending}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<Trans>Confirm</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant="default"
|
||||||
|
onClick={openForm}
|
||||||
|
disabled={confirm.isPending}
|
||||||
|
>
|
||||||
|
<Trans>Not right</Trans>
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
{formSheetEl}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||||
|
<Trans>
|
||||||
|
Pending: {scoreText} · reported by {teamName(reportingTeam) ?? t`a team`}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { SeedBadge } from "./seed-badge";
|
|||||||
import { TeamInfo } from "@/features/teams/types";
|
import { TeamInfo } from "@/features/teams/types";
|
||||||
import AnimatedScore from "@/features/matches/components/animated-score";
|
import AnimatedScore from "@/features/matches/components/animated-score";
|
||||||
import classes from "./match-slot.module.css";
|
import classes from "./match-slot.module.css";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
export type MatchSlotState = "winner" | "correct" | "incorrect";
|
export type MatchSlotState = "winner" | "correct" | "incorrect";
|
||||||
|
|
||||||
@@ -97,11 +98,15 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
) : from ? (
|
) : from ? (
|
||||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
{from_loser ? "Loser" : "Winner"} of Match {from}
|
{from_loser ? (
|
||||||
|
<Trans>Loser of Match {from}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Winner of Match {from}</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
TBD
|
<Trans>TBD</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Flex, Text, Select, Card } from "@mantine/core";
|
import { Flex, Text, Select, Card } from "@mantine/core";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface Team {
|
interface Team {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,9 +12,10 @@ interface SeedListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
||||||
|
const { t } = useLingui();
|
||||||
const seedOptions = teams.map((_, index) => ({
|
const seedOptions = teams.map((_, index) => ({
|
||||||
value: index.toString(),
|
value: index.toString(),
|
||||||
label: `Seed ${index + 1}`,
|
label: t`Seed ${index + 1}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
|
import { msg } from "@lingui/core/macro";
|
||||||
|
import type { I18n } from "@lingui/core";
|
||||||
|
|
||||||
export interface GroupConfig {
|
export interface GroupConfig {
|
||||||
num_groups: number;
|
num_groups: number;
|
||||||
advance_per_group: number;
|
advance_per_group: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wildcardMsg = msg`Wildcard {n}`;
|
||||||
|
const groupRankMsg = msg`{group} {rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}`;
|
||||||
|
|
||||||
|
export const formatWildcardLabel = (i18n: I18n, n: number): string =>
|
||||||
|
i18n._({ ...wildcardMsg, values: { n } });
|
||||||
|
|
||||||
|
export const formatGroupRankLabel = (
|
||||||
|
i18n: I18n,
|
||||||
|
group: string,
|
||||||
|
rank: number
|
||||||
|
): string => i18n._({ ...groupRankMsg, values: { group, rank } });
|
||||||
|
|
||||||
export function getGroupLabel(
|
export function getGroupLabel(
|
||||||
|
i18n: I18n,
|
||||||
seed: number | undefined,
|
seed: number | undefined,
|
||||||
groupConfig: GroupConfig | undefined
|
groupConfig: GroupConfig | undefined
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
@@ -19,7 +35,7 @@ export function getGroupLabel(
|
|||||||
|
|
||||||
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
|
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
|
||||||
const wildcardNumber = seed - totalQualifiedTeams;
|
const wildcardNumber = seed - totalQualifiedTeams;
|
||||||
return `Wildcard ${wildcardNumber}`;
|
return formatWildcardLabel(i18n, wildcardNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pairIndex = Math.floor((seed - 1) / 2);
|
const pairIndex = Math.floor((seed - 1) / 2);
|
||||||
@@ -31,19 +47,15 @@ export function getGroupLabel(
|
|||||||
|
|
||||||
const rank = rankIndex + 1;
|
const rank = rankIndex + 1;
|
||||||
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
||||||
const rankSuffix =
|
|
||||||
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
|
|
||||||
|
|
||||||
return `${groupName} ${rankSuffix}`;
|
return formatGroupRankLabel(i18n, groupName, rank);
|
||||||
} else {
|
} else {
|
||||||
const groupIndex = (pairIndex + 1) % numGroups;
|
const groupIndex = (pairIndex + 1) % numGroups;
|
||||||
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
|
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
|
||||||
|
|
||||||
const rank = rankIndex + 1;
|
const rank = rankIndex + 1;
|
||||||
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
||||||
const rankSuffix =
|
|
||||||
rank === 1 ? "1st" : rank === 2 ? "2nd" : rank === 3 ? "3rd" : `${rank}th`;
|
|
||||||
|
|
||||||
return `${groupName} ${rankSuffix}`;
|
return formatGroupRankLabel(i18n, groupName, rank);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { UnstyledButton } from "@mantine/core"
|
import { UnstyledButton } from "@mantine/core"
|
||||||
import { ArrowLeftIcon } from "@phosphor-icons/react"
|
import { ArrowLeftIcon } from "@phosphor-icons/react"
|
||||||
import { useRouter } from "@tanstack/react-router"
|
import { useRouter } from "@tanstack/react-router"
|
||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
|
||||||
const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
|
const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
aria-label='Go back'
|
aria-label={t`Go back`}
|
||||||
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
|
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
|
||||||
onClick={() => router.history.back()}
|
onClick={() => router.history.back()}
|
||||||
pos='absolute'
|
pos='absolute'
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Title, AppShell, Flex } from "@mantine/core";
|
import { Title, AppShell, Flex } from "@mantine/core";
|
||||||
|
import { useLingui } from "@lingui/react";
|
||||||
import { HeaderConfig } from "../types/header-config";
|
import { HeaderConfig } from "../types/header-config";
|
||||||
import BackButton from "./back-button";
|
import BackButton from "./back-button";
|
||||||
|
|
||||||
const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => {
|
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
const resolvedTitle =
|
||||||
|
typeof title === "string" || title === undefined
|
||||||
|
? title
|
||||||
|
: i18n._({ ...title, values: titleValues });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
id='app-header'
|
id='app-header'
|
||||||
@@ -15,7 +22,7 @@ const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => {
|
|||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' px='md' mt={8}>
|
<Flex justify='center' px='md' mt={8}>
|
||||||
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
|
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
|
||||||
{title?.toLocaleUpperCase()}
|
{resolvedTitle?.toLocaleUpperCase()}
|
||||||
</Title>
|
</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AuthProvider } from "@/contexts/auth-context"
|
import { AuthProvider } from "@/contexts/auth-context"
|
||||||
import { SpotifyProvider } from "@/contexts/spotify-context"
|
import { SpotifyProvider } from "@/contexts/spotify-context"
|
||||||
|
import { LinguiProvider } from "@/lib/i18n/provider"
|
||||||
import MantineProvider from "@/lib/mantine/mantine-provider"
|
import MantineProvider from "@/lib/mantine/mantine-provider"
|
||||||
//import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
//import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||||
//import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
//import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
@@ -8,7 +9,8 @@ import { Toaster } from "sonner"
|
|||||||
|
|
||||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<LinguiProvider>
|
||||||
|
<AuthProvider>
|
||||||
<SpotifyProvider>
|
<SpotifyProvider>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
{/*<TanStackDevtools
|
{/*<TanStackDevtools
|
||||||
@@ -31,7 +33,8 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
|||||||
{children}
|
{children}
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</SpotifyProvider>
|
</SpotifyProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</LinguiProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { UnstyledButton } from "@mantine/core"
|
import { UnstyledButton } from "@mantine/core"
|
||||||
import { GearIcon } from "@phosphor-icons/react"
|
import { GearIcon } from "@phosphor-icons/react"
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
|
||||||
interface SettingButtonProps {
|
interface SettingButtonProps {
|
||||||
@@ -11,10 +12,11 @@ interface SettingButtonProps {
|
|||||||
|
|
||||||
const SettingsButton = ({ to }: SettingButtonProps) => {
|
const SettingsButton = ({ to }: SettingButtonProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
aria-label='Settings'
|
aria-label={t`Settings`}
|
||||||
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
|
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
|
||||||
onClick={() => navigate({ to })}
|
onClick={() => navigate({ to })}
|
||||||
pos='absolute'
|
pos='absolute'
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
export const useLinks = (userId: string | undefined, roles: string[]) =>
|
export const useLinks = (userId: string | undefined, roles: string[]) => {
|
||||||
useMemo(() => {
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
label: 'Home',
|
label: t`Home`,
|
||||||
href: '/',
|
href: '/',
|
||||||
Icon: HouseIcon
|
Icon: HouseIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Statistics',
|
label: t`Statistics`,
|
||||||
href: '/stats',
|
href: '/stats',
|
||||||
Icon: RankingIcon
|
Icon: RankingIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Tournaments',
|
label: t`Tournaments`,
|
||||||
href: '/tournaments',
|
href: '/tournaments',
|
||||||
Icon: TrophyIcon,
|
Icon: TrophyIcon,
|
||||||
exclude: ['/admin/tournaments']
|
exclude: ['/admin/tournaments']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Profile',
|
label: t`Profile`,
|
||||||
href: `/profile/${userId}`,
|
href: `/profile/${userId}`,
|
||||||
Icon: UserCircleIcon,
|
Icon: UserCircleIcon,
|
||||||
include: ['/settings']
|
include: ['/settings']
|
||||||
@@ -30,11 +33,12 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
|
|||||||
|
|
||||||
if (roles.includes('Admin')) {
|
if (roles.includes('Admin')) {
|
||||||
links.push({
|
links.push({
|
||||||
label: 'Admin',
|
label: t`Admin`,
|
||||||
href: '/admin',
|
href: '/admin',
|
||||||
Icon: ShieldIcon
|
Icon: ShieldIcon
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return links;
|
return links;
|
||||||
}, [userId, roles]);
|
}, [userId, roles, t]);
|
||||||
|
};
|
||||||
@@ -16,14 +16,14 @@ const useRouterConfig = () => {
|
|||||||
match?.loaderData && 'header' in match.loaderData
|
match?.loaderData && 'header' in match.loaderData
|
||||||
);
|
);
|
||||||
|
|
||||||
const headerConfig = matchesWithHeader.reduce((acc, match) => {
|
const headerConfig = matchesWithHeader.reduce<HeaderConfig>((acc, match) => {
|
||||||
const loaderData = match?.loaderData;
|
const loaderData = match?.loaderData;
|
||||||
if (loaderData && typeof loaderData === 'object' && 'header' in loaderData) {
|
if (loaderData && typeof loaderData === 'object' && 'header' in loaderData) {
|
||||||
const header = loaderData.header;
|
const header = loaderData.header;
|
||||||
if (header && typeof header === 'object') {
|
if (header && typeof header === 'object') {
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
...header,
|
...(header as HeaderConfig),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Serializable subset of Lingui's MessageDescriptor — header config passes
|
||||||
|
* through route loaders/beforeLoad, whose results must be serializable.
|
||||||
|
* msg`` descriptors are assignable to this shape.
|
||||||
|
*/
|
||||||
|
interface HeaderTitleMessage {
|
||||||
|
id: string;
|
||||||
|
message?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface HeaderConfig {
|
interface HeaderConfig {
|
||||||
title?: string;
|
/** Plain strings render as-is (data-derived names); use a msg`` descriptor for translatable titles. */
|
||||||
|
title?: string | HeaderTitleMessage;
|
||||||
|
/** ICU values when title is a descriptor, e.g. msg`Manage {name}` + { name }. */
|
||||||
|
titleValues?: Record<string, string | number>;
|
||||||
withBackButton?: boolean;
|
withBackButton?: boolean;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
settingsLink?: string;
|
settingsLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { HeaderConfig };
|
export type { HeaderConfig, HeaderTitleMessage };
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { useState } from 'react';
|
|||||||
import { Flex, PinInput, Title, Text, Stack, LoadingOverlay } from '@mantine/core';
|
import { Flex, PinInput, Title, Text, Stack, LoadingOverlay } from '@mantine/core';
|
||||||
import useConsumeCode from '../hooks/use-consume-code';
|
import useConsumeCode from '../hooks/use-consume-code';
|
||||||
import { useSearch } from '@tanstack/react-router';
|
import { useSearch } from '@tanstack/react-router';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
|
||||||
const CodePrompt = () => {
|
const CodePrompt = () => {
|
||||||
const { number } = useSearch({ from: '/login' });
|
const { number } = useSearch({ from: '/login' });
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const [isWrong, setIsWrong] = useState(false);
|
const [isWrong, setIsWrong] = useState(false);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
@@ -24,10 +26,10 @@ const CodePrompt = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" p={10} w='max-content' m='auto'>
|
<Flex direction="column" p={10} w='max-content' m='auto'>
|
||||||
<Title order={4}>Enter Verification Code</Title>
|
<Title order={4}><Trans>Enter Verification Code</Trans></Title>
|
||||||
<Text size='xs'c="dimmed" mb={5}>A code was sent to +1 ({number?.slice(0, 3)}) {number?.slice(3, 6)}-{number?.slice(6)}</Text>
|
<Text size='xs'c="dimmed" mb={5}><Trans>A code was sent to +1 ({number?.slice(0, 3)}) {number?.slice(3, 6)}-{number?.slice(6)}</Trans></Text>
|
||||||
<Stack justify='center' p={10} gap={2} pos='relative'>
|
<Stack justify='center' p={10} gap={2} pos='relative'>
|
||||||
<PinInput aria-label="One time code"
|
<PinInput aria-label={t`One time code`}
|
||||||
value={code}
|
value={code}
|
||||||
error={isWrong}
|
error={isWrong}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -38,7 +40,7 @@ const CodePrompt = () => {
|
|||||||
type='number'
|
type='number'
|
||||||
/>
|
/>
|
||||||
<LoadingOverlay visible={isPending} overlayProps={{ blur: 0.375, radius: 'md', backgroundOpacity: 0.35 }} />
|
<LoadingOverlay visible={isPending} overlayProps={{ blur: 0.375, radius: 'md', backgroundOpacity: 0.35 }} />
|
||||||
{isWrong && <Text c='red' size='xs'>Incorrect code</Text>}
|
{isWrong && <Text c='red' size='xs'><Trans>Incorrect code</Trans></Text>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Flex>
|
</Flex>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import GlitchAvatar from '@/components/glitch-avatar';
|
import GlitchAvatar from '@/components/glitch-avatar';
|
||||||
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
|
||||||
import { useCurrentTournament } from '@/features/tournaments/queries';
|
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 { useMediaQuery, useViewportSize } from '@mantine/hooks';
|
||||||
import { TrophyIcon } from '@phosphor-icons/react';
|
import { TrophyIcon } from '@phosphor-icons/react';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
|
||||||
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
|
||||||
@@ -27,7 +28,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
style={{ transition: 'padding-top 0.1s ease' }}
|
style={{ transition: 'padding-top 0.1s ease' }}
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
shadow='md'
|
|
||||||
p='md'
|
p='md'
|
||||||
w='100%'
|
w='100%'
|
||||||
maw='375px'
|
maw='375px'
|
||||||
@@ -52,12 +52,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</GlitchAvatar>
|
</GlitchAvatar>
|
||||||
<Stack align='center' gap={2}>
|
<Title order={1} ta='center'><Trans>Welcome to FLXN</Trans></Title>
|
||||||
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
|
||||||
<Text size='sm' c='dimmed' fs='italic' ta='center'>
|
|
||||||
Amicus meus madidus
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { TextInput } from "@mantine/core";
|
import { TextInput } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import useCreateUser from "../hooks/use-create-user";
|
import useCreateUser from "../hooks/use-create-user";
|
||||||
import Button from "@/components/button";
|
import Button from "@/components/button";
|
||||||
|
|
||||||
const NamePrompt = () => {
|
const NamePrompt = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
first_name: '',
|
first_name: '',
|
||||||
@@ -11,12 +13,12 @@ const NamePrompt = () => {
|
|||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
first_name: (value) => {
|
first_name: (value) => {
|
||||||
if (value.length === 0) return 'First name is required'
|
if (value.length === 0) return t`First name is required`
|
||||||
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
|
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return t`First name must be 3-20 characters long and contain only letters`
|
||||||
},
|
},
|
||||||
last_name: (value) => {
|
last_name: (value) => {
|
||||||
if (value.length === 0) return 'Last name is required'
|
if (value.length === 0) return t`Last name is required`
|
||||||
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
|
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return t`Last name must be 3-20 characters long and contain only letters`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -32,17 +34,17 @@ const NamePrompt = () => {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="first_name"
|
id="first_name"
|
||||||
label='First Name'
|
label={t`First Name`}
|
||||||
key={form.key('first_name')}
|
key={form.key('first_name')}
|
||||||
{...form.getInputProps('first_name')}
|
{...form.getInputProps('first_name')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="last_name"
|
id="last_name"
|
||||||
label='Last Name'
|
label={t`Last Name`}
|
||||||
key={form.key('last_name')}
|
key={form.key('last_name')}
|
||||||
{...form.getInputProps('last_name')}
|
{...form.getInputProps('last_name')}
|
||||||
/>
|
/>
|
||||||
<Button loading={isPending} type='submit' mt='10px' variant='filled'>Create Account</Button>
|
<Button loading={isPending} type='submit' mt='10px' variant='filled'><Trans>Create Account</Trans></Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import PhoneNumberInput from "@/components/phone-number-input";
|
import PhoneNumberInput from "@/components/phone-number-input";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import useCreateCode from "../hooks/use-create-code";
|
import useCreateCode from "../hooks/use-create-code";
|
||||||
|
|
||||||
const PhonePrompt = () => {
|
const PhonePrompt = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
number: ''
|
number: ''
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
number: (value) => {
|
number: (value) => {
|
||||||
if (value.length === 0) return 'Phone number is required'
|
if (value.length === 0) return t`Phone number is required`
|
||||||
if (value.length !== 10) return 'Phone number must be 10 digits'
|
if (value.length !== 10) return t`Phone number must be 10 digits`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -27,11 +29,11 @@ const PhonePrompt = () => {
|
|||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
<PhoneNumberInput
|
<PhoneNumberInput
|
||||||
id="number"
|
id="number"
|
||||||
label='Enter your phone number'
|
label={t`Enter your phone number`}
|
||||||
key={form.key('number')}
|
key={form.key('number')}
|
||||||
{...form.getInputProps('number')}
|
{...form.getInputProps('number')}
|
||||||
/>
|
/>
|
||||||
<Button type='submit' w='100%' mt='10px' variant='filled' loading={isPending}>Send Code</Button>
|
<Button type='submit' w='100%' mt='10px' variant='filled' loading={isPending}><Trans>Send Code</Trans></Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from "@/components/button";
|
import Button from "@/components/button";
|
||||||
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
|
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
|
||||||
import { ChalkboardTeacherIcon } from "@phosphor-icons/react";
|
import { ChalkboardTeacherIcon } from "@phosphor-icons/react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
|
const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
|
||||||
return <Button
|
return <Button
|
||||||
@@ -14,7 +15,7 @@ const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) =>
|
|||||||
<Center>
|
<Center>
|
||||||
<ChalkboardTeacherIcon size='3rem' />
|
<ChalkboardTeacherIcon size='3rem' />
|
||||||
</Center>
|
</Center>
|
||||||
<Text size='md' fw={600}>Returning Player</Text>
|
<Text size='md' fw={600}><Trans>Returning Player</Trans></Text>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Button>
|
</Button>
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, FormEventHandler, useMemo } from 'react';
|
import { useState, FormEventHandler, useMemo } from 'react';
|
||||||
import { ArrowLeftIcon } from '@phosphor-icons/react';
|
import { ArrowLeftIcon } from '@phosphor-icons/react';
|
||||||
import { Autocomplete, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
|
import { Autocomplete, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import ExistingPlayerButton from './existing-player-button';
|
import ExistingPlayerButton from './existing-player-button';
|
||||||
import NewPlayerButton from './new-player-button';
|
import NewPlayerButton from './new-player-button';
|
||||||
import { Player } from '@/features/players/types';
|
import { Player } from '@/features/players/types';
|
||||||
@@ -15,6 +16,7 @@ enum PlayerPromptStage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PlayerPrompt = () => {
|
const PlayerPrompt = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const [stage, setStage] = useState<PlayerPromptStage>();
|
const [stage, setStage] = useState<PlayerPromptStage>();
|
||||||
const playersQuery = useUnassociatedPlayers();
|
const playersQuery = useUnassociatedPlayers();
|
||||||
const { mutate: createUser, isPending } = useCreateUser();
|
const { mutate: createUser, isPending } = useCreateUser();
|
||||||
@@ -41,7 +43,7 @@ const PlayerPrompt = () => {
|
|||||||
|
|
||||||
// check if player already exists
|
// check if player already exists
|
||||||
if (!!parsedPlayers?.find(p => p.label === value)) {
|
if (!!parsedPlayers?.find(p => p.label === value)) {
|
||||||
toast.error("Player already exists");
|
toast.error(t`Player already exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ const PlayerPrompt = () => {
|
|||||||
setError('');
|
setError('');
|
||||||
createUser(player.id!);
|
createUser(player.id!);
|
||||||
} else {
|
} else {
|
||||||
setError('You must select a player from the dropdown. If you don\'t see yourself, please go back and select \'New Player\'');
|
setError(t`You must select a player from the dropdown. If you don't see yourself, please go back and select 'New Player'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +78,8 @@ const PlayerPrompt = () => {
|
|||||||
|
|
||||||
if (!stage) {
|
if (!stage) {
|
||||||
return <>
|
return <>
|
||||||
<Title order={3}>Have you played before?</Title>
|
<Title order={3}><Trans>Have you played before?</Trans></Title>
|
||||||
<Text size='xs' mb='sm'>If this is your first time participating, please select <i>New Player</i>, otherwise select <i>Returning Player</i></Text>
|
<Text size='xs' mb='sm'><Trans>If this is your first time participating, please select <i>New Player</i>, otherwise select <i>Returning Player</i></Trans></Text>
|
||||||
<Flex justify='space-around'>
|
<Flex justify='space-around'>
|
||||||
<ExistingPlayerButton onClick={() => setStage(PlayerPromptStage.returning)} />
|
<ExistingPlayerButton onClick={() => setStage(PlayerPromptStage.returning)} />
|
||||||
<Divider orientation='vertical' variant="dashed" />
|
<Divider orientation='vertical' variant="dashed" />
|
||||||
@@ -88,7 +90,7 @@ const PlayerPrompt = () => {
|
|||||||
|
|
||||||
return <>
|
return <>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
aria-label="Go back"
|
aria-label={t`Go back`}
|
||||||
onClick={() => setStage(undefined)}
|
onClick={() => setStage(undefined)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -105,23 +107,23 @@ const PlayerPrompt = () => {
|
|||||||
<>
|
<>
|
||||||
<form onSubmit={formSubmitHandler(handleNewPlayerSubmit)}>
|
<form onSubmit={formSubmitHandler(handleNewPlayerSubmit)}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label='Enter your name'
|
label={t`Enter your name`}
|
||||||
placeholder='Salah Atiyeh'
|
placeholder={t`Salah Atiyeh`}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleNewPlayerChange}
|
onChange={handleNewPlayerChange}
|
||||||
/>
|
/>
|
||||||
<Button type='submit' mt='10px' color='green' variant='filled'>Submit</Button>
|
<Button type='submit' mt='10px' color='green' variant='filled'><Trans>Submit</Trans></Button>
|
||||||
</form>
|
</form>
|
||||||
</> :
|
</> :
|
||||||
<form onSubmit={formSubmitHandler(handlePlayerSubmit)}>
|
<form onSubmit={formSubmitHandler(handlePlayerSubmit)}>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
label='Enter your name'
|
label={t`Enter your name`}
|
||||||
placeholder='Salah Atiyeh'
|
placeholder={t`Salah Atiyeh`}
|
||||||
data={autocompleteOptions}
|
data={autocompleteOptions}
|
||||||
onChange={handleReturningPlayerChange}
|
onChange={handleReturningPlayerChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
<Button type='submit' mt='10px' color='green' variant='filled'>Submit</Button>
|
<Button type='submit' mt='10px' color='green' variant='filled'><Trans>Submit</Trans></Button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Button from "@/components/button";
|
import Button from "@/components/button";
|
||||||
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
|
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
|
||||||
import { UserPlusIcon } from "@phosphor-icons/react";
|
import { UserPlusIcon } from "@phosphor-icons/react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
|
const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
|
||||||
return <Button
|
return <Button
|
||||||
@@ -14,7 +15,7 @@ const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
|
|||||||
<Center>
|
<Center>
|
||||||
<UserPlusIcon size='3rem' />
|
<UserPlusIcon size='3rem' />
|
||||||
</Center>
|
</Center>
|
||||||
<Text size='md' fw={600}>New Player</Text>
|
<Text size='md' fw={600}><Trans>New Player</Trans></Text>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Button>
|
</Button>
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { fetchMe } from "@/features/players/server";
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { playerKeys } from "@/features/players/queries";
|
import { playerKeys } from "@/features/players/queries";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
const useConsumeCode = (onWrongCode: () => void) => {
|
const useConsumeCode = (onWrongCode: () => void) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
|
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
|
||||||
@@ -18,18 +20,18 @@ const useConsumeCode = (onWrongCode: () => void) => {
|
|||||||
navigate({ to: '/login', search: { stage: 'name' } });
|
navigate({ to: '/login', search: { stage: 'name' } });
|
||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(playerKeys.auth, response.data);
|
queryClient.setQueryData(playerKeys.auth, response.data);
|
||||||
toast.success('Successfully logged in. Welcome back!');
|
toast.success(t`Successfully logged in. Welcome back!`);
|
||||||
navigate({ to: '/' })
|
navigate({ to: '/' })
|
||||||
}
|
}
|
||||||
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
|
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
|
||||||
onWrongCode();
|
onWrongCode();
|
||||||
} else if (data.status === 'EXPIRED_USER_INPUT_CODE_ERROR') {
|
} else if (data.status === 'EXPIRED_USER_INPUT_CODE_ERROR') {
|
||||||
toast.error('Code has expired. Please request a new code.');
|
toast.error(t`Code has expired. Please request a new code.`);
|
||||||
} else if (data.status === "RESTART_FLOW_ERROR") {
|
} else if (data.status === "RESTART_FLOW_ERROR") {
|
||||||
toast.error('Too many failed attempts. Please try again.');
|
toast.error(t`Too many failed attempts. Please try again.`);
|
||||||
navigate({ to: '/login', search: { stage: undefined, number: undefined } });
|
navigate({ to: '/login', search: { stage: undefined, number: undefined } });
|
||||||
} else {
|
} else {
|
||||||
toast.error('Unknown error. Please try again later.');
|
toast.error(t`Unknown error. Please try again later.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -38,7 +40,7 @@ const useConsumeCode = (onWrongCode: () => void) => {
|
|||||||
if (error.isSuperTokensGeneralError === true) {
|
if (error.isSuperTokensGeneralError === true) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
} else {
|
} else {
|
||||||
toast.error("Unknown error. Please try again later.");
|
toast.error(t`Unknown error. Please try again later.`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import { createCode } from "supertokens-web-js/recipe/passwordless";
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
const useCreateCode = () => {
|
const useCreateCode = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (phoneNumber: string) => createCode({ phoneNumber: '+1' + phoneNumber }),
|
mutationFn: (phoneNumber: string) => createCode({ phoneNumber: '+1' + phoneNumber }),
|
||||||
onSuccess: (data, phoneNumber) => {
|
onSuccess: (data, phoneNumber) => {
|
||||||
if (data.status === 'OK') {
|
if (data.status === 'OK') {
|
||||||
toast.success('Code sent successfully');
|
toast.success(t`Code sent successfully`);
|
||||||
navigate({ to: '/login', search: { stage: 'code', number: phoneNumber } });
|
navigate({ to: '/login', search: { stage: 'code', number: phoneNumber } });
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.reason);
|
toast.error(data.reason);
|
||||||
@@ -20,7 +22,7 @@ const useCreateCode = () => {
|
|||||||
if (error.isSuperTokensGeneralError === true) {
|
if (error.isSuperTokensGeneralError === true) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
} else {
|
} else {
|
||||||
toast.error('An unexpected error occurred when trying to send a one-time passcode. Please try again later.');
|
toast.error(t`An unexpected error occurred when trying to send a one-time passcode. Please try again later.`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import { associatePlayer, createPlayer } from "@/features/players/server";
|
import { associatePlayer, createPlayer } from "@/features/players/server";
|
||||||
import { playerKeys } from "@/features/players/queries";
|
import { playerKeys } from "@/features/players/queries";
|
||||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
@@ -7,13 +8,14 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
|||||||
const useCreateUser = () => {
|
const useCreateUser = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
return useServerMutation({
|
return useServerMutation({
|
||||||
mutationFn: (data: { first_name: string, last_name: string } | string) =>
|
mutationFn: (data: { first_name: string, last_name: string } | string) =>
|
||||||
typeof data === 'string' ?
|
typeof data === 'string' ?
|
||||||
associatePlayer({ data })
|
associatePlayer({ data })
|
||||||
: createPlayer({ data }),
|
: createPlayer({ data }),
|
||||||
successMessage: 'Account created successfully!',
|
successMessage: t`Account created successfully!`,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.setQueryData(playerKeys.auth, (old: any) => ({
|
queryClient.setQueryData(playerKeys.auth, (old: any) => ({
|
||||||
...old,
|
...old,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useSheet } from "@/hooks/use-sheet";
|
|||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
|
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
|
||||||
import AnimatedScore from "./animated-score";
|
import AnimatedScore from "./animated-score";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -18,6 +19,7 @@ interface MatchCardProps {
|
|||||||
const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const h2hSheet = useSheet();
|
const h2hSheet = useSheet();
|
||||||
|
const { t } = useLingui();
|
||||||
const isHomeWin = match.home_cups > match.away_cups;
|
const isHomeWin = match.home_cups > match.away_cups;
|
||||||
const isAwayWin = match.away_cups > match.home_cups;
|
const isAwayWin = match.away_cups > match.home_cups;
|
||||||
const isStarted = match.status === "started";
|
const isStarted = match.status === "started";
|
||||||
@@ -71,19 +73,19 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
|||||||
<>
|
<>
|
||||||
<Text c="dimmed">-</Text>
|
<Text c="dimmed">-</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Round {match.round + 1}
|
<Trans>Round {match.round + 1}</Trans>
|
||||||
{match.is_losers_bracket && " (Losers)"}
|
{match.is_losers_bracket && <Trans> (Losers)</Trans>}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{match.home && match.away && !hideH2H && !hasPrivate && (
|
{match.home && match.away && !hideH2H && !hasPrivate && (
|
||||||
<Tooltip label="Head to Head" withArrow position="left">
|
<Tooltip label={t`Head to Head`} withArrow position="left">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleH2HClick}
|
onClick={handleH2HClick}
|
||||||
aria-label="View head-to-head"
|
aria-label={t`View head-to-head`}
|
||||||
w={40}
|
w={40}
|
||||||
>
|
>
|
||||||
<Group style={{ position: 'relative', width: 27.5, height: 16 }}>
|
<Group style={{ position: 'relative', width: 27.5, height: 16 }}>
|
||||||
@@ -226,7 +228,7 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
|||||||
|
|
||||||
{match.home && match.away && !hideH2H && h2hSheet.isOpen && (
|
{match.home && match.away && !hideH2H && h2hSheet.isOpen && (
|
||||||
<Sheet
|
<Sheet
|
||||||
title="Head to Head"
|
title={t`Head to Head`}
|
||||||
{...h2hSheet.props}
|
{...h2hSheet.props}
|
||||||
>
|
>
|
||||||
<TeamHeadToHeadSheet team1={match.home} team2={match.away} isOpen={h2hSheet.props.opened} />
|
<TeamHeadToHeadSheet team1={match.home} team2={match.away} isOpen={h2hSheet.props.opened} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Stack, Text } from "@mantine/core";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import MatchCard from "./match-card";
|
import MatchCard from "./match-card";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface MatchListProps {
|
interface MatchListProps {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
@@ -29,7 +30,7 @@ const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
|
|||||||
<Stack p="md" gap="sm">
|
<Stack p="md" gap="sm">
|
||||||
{isRegional && (
|
{isRegional && (
|
||||||
<Text size="xs" c="dimmed" ta="center" px="md">
|
<Text size="xs" c="dimmed" ta="center" px="md">
|
||||||
Matches for regionals are unordered
|
<Trans>Matches for regionals are unordered</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{filteredMatches.map((match, index) => (
|
{filteredMatches.map((match, index) => (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useMemo, useEffect, useState, Suspense } from "react";
|
|||||||
import { CrownIcon } from "@phosphor-icons/react";
|
import { CrownIcon } from "@phosphor-icons/react";
|
||||||
import MatchList from "./match-list";
|
import MatchList from "./match-list";
|
||||||
import TeamHeadToHeadSkeleton from "./team-head-to-head-skeleton";
|
import TeamHeadToHeadSkeleton from "./team-head-to-head-skeleton";
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro";
|
||||||
|
|
||||||
interface TeamHeadToHeadSheetProps {
|
interface TeamHeadToHeadSheetProps {
|
||||||
team1: TeamInfo;
|
team1: TeamInfo;
|
||||||
@@ -87,7 +88,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Stack p="md" gap="md">
|
<Stack p="md" gap="md">
|
||||||
<Text size="sm" c="dimmed" ta="center">Loading...</Text>
|
<Text size="sm" c="dimmed" ta="center"><Trans>Loading...</Trans></Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
return (
|
return (
|
||||||
<Stack p="md" gap="md">
|
<Stack p="md" gap="md">
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
These teams have not faced each other yet.
|
<Trans>These teams have not faced each other yet.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -111,7 +112,7 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="center" gap="xs">
|
<Group justify="center" gap="xs">
|
||||||
<Text size="lg" fw={700}>{team1.name}</Text>
|
<Text size="lg" fw={700}>{team1.name}</Text>
|
||||||
<Text size="sm" c="dimmed">vs</Text>
|
<Text size="sm" c="dimmed"><Trans>vs</Trans></Text>
|
||||||
<Text size="lg" fw={700}>{team2.name}</Text>
|
<Text size="lg" fw={700}>{team2.name}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -131,32 +132,32 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
<Group justify="center" gap="xs">
|
<Group justify="center" gap="xs">
|
||||||
<CrownIcon size={16} weight="fill" color="gold" />
|
<CrownIcon size={16} weight="fill" color="gold" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{leader.name} leads the series
|
<Trans>{leader.name} leads the series</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!leader && totalMatches > 0 && (
|
{!leader && totalMatches > 0 && (
|
||||||
<Text size="xs" c="dimmed" ta="center">
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
Series is tied
|
<Trans>Series is tied</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm" fw={600} px="md" mb="xs">Stats Comparison</Text>
|
<Text size="sm" fw={600} px="md" mb="xs"><Trans>Stats Comparison</Trans></Text>
|
||||||
|
|
||||||
<Paper withBorder>
|
<Paper withBorder>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Group justify="space-between" px="md" py="sm">
|
<Group justify="space-between" px="md" py="sm">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="sm" fw={600}>{stats.team1CupsFor}</Text>
|
<Text size="sm" fw={600}>{stats.team1CupsFor}</Text>
|
||||||
<Text size="xs" c="dimmed">cups</Text>
|
<Text size="xs" c="dimmed"><Trans>cups</Trans></Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" fw={500}>Total Cups</Text>
|
<Text size="xs" fw={500}><Trans>Total Cups</Trans></Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">cups</Text>
|
<Text size="xs" c="dimmed"><Trans>cups</Trans></Text>
|
||||||
<Text size="sm" fw={600}>{stats.team2CupsFor}</Text>
|
<Text size="sm" fw={600}>{stats.team2CupsFor}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -167,11 +168,11 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{totalMatches > 0 ? (stats.team1CupsFor / totalMatches).toFixed(1) : '0.0'}
|
{totalMatches > 0 ? (stats.team1CupsFor / totalMatches).toFixed(1) : '0.0'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">avg</Text>
|
<Text size="xs" c="dimmed"><Trans>avg</Trans></Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" fw={500}>Avg Cups/Match</Text>
|
<Text size="xs" fw={500}><Trans>Avg Cups/Match</Trans></Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">avg</Text>
|
<Text size="xs" c="dimmed"><Trans>avg</Trans></Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{totalMatches > 0 ? (stats.team2CupsFor / totalMatches).toFixed(1) : '0.0'}
|
{totalMatches > 0 ? (stats.team2CupsFor / totalMatches).toFixed(1) : '0.0'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -184,11 +185,11 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{!isNaN(stats.team1AvgMargin) ? stats.team1AvgMargin.toFixed(1) : '0.0'}
|
{!isNaN(stats.team1AvgMargin) ? stats.team1AvgMargin.toFixed(1) : '0.0'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">margin</Text>
|
<Text size="xs" c="dimmed"><Trans>margin</Trans></Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" fw={500}>Avg Win Margin</Text>
|
<Text size="xs" fw={500}><Trans>Avg Win Margin</Trans></Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">margin</Text>
|
<Text size="xs" c="dimmed"><Trans>margin</Trans></Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{!isNaN(stats.team2AvgMargin) ? stats.team2AvgMargin.toFixed(1) : '0.0'}
|
{!isNaN(stats.team2AvgMargin) ? stats.team2AvgMargin.toFixed(1) : '0.0'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -199,7 +200,9 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm" fw={600} px="md">Match History ({totalMatches} match{totalMatches !== 1 ? 'es' : ''})</Text>
|
<Text size="sm" fw={600} px="md">
|
||||||
|
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
|
||||||
|
</Text>
|
||||||
<MatchList matches={matches} hideH2H />
|
<MatchList matches={matches} hideH2H />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { getMatchesBetweenTeams, getMatchesBetweenPlayers } from "./server";
|
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 = {
|
export const matchKeys = {
|
||||||
headToHeadTeams: (team1Id: string, team2Id: string) => ['matches', 'headToHead', 'teams', team1Id, team2Id] as const,
|
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),
|
...matchQueries.headToHeadPlayers(player1Id, player2Id),
|
||||||
enabled,
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
+469
-62
@@ -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 }>();
|
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 || []) {
|
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;
|
const teamId = typeof team === 'string' ? team : team.id;
|
||||||
standings.set(teamId, {
|
standings.set(teamId, {
|
||||||
teamId,
|
teamId,
|
||||||
@@ -456,6 +455,344 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
|
|||||||
logger.info('Knockout bracket populated successfully', { tournamentId });
|
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({
|
const endMatchSchema = z.object({
|
||||||
matchId: z.string(),
|
matchId: z.string(),
|
||||||
home_cups: z.number(),
|
home_cups: z.number(),
|
||||||
@@ -469,83 +806,153 @@ export const endMatch = createServerFn()
|
|||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
logger.info("Ending match", matchId);
|
logger.info("Ending match", matchId);
|
||||||
|
|
||||||
let match = await pbAdmin.getMatch(matchId);
|
const match = await pbAdmin.getMatch(matchId);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error("Match not found");
|
throw new Error("Match not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
match = await pbAdmin.updateMatch(matchId, {
|
return finalizeMatch(matchId, { home_cups, away_cups, ot_count });
|
||||||
end_time: new Date().toISOString(),
|
})
|
||||||
status: "ended",
|
);
|
||||||
home_cups,
|
|
||||||
away_cups,
|
const teamPlayerIds = (team: Match["home"]): string[] =>
|
||||||
ot_count,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (match.lid === -1) {
|
emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
|
||||||
emitServerEvent({
|
return { success: true as const, finalized: false as const };
|
||||||
type: "match",
|
})
|
||||||
matchId: match.id,
|
);
|
||||||
tournamentId: match.tournament.id
|
|
||||||
});
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
const matchIdSchema = z.object({ matchId: z.string() });
|
||||||
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);
|
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");
|
||||||
|
|
||||||
if (winner && winner.reset) {
|
const match = await pbAdmin.getMatch(matchId);
|
||||||
const awayTeamWon = match.away === matchWinner;
|
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");
|
||||||
|
|
||||||
if (!awayTeamWon) {
|
const onHome = teamPlayerIds(match.home).includes(player.id);
|
||||||
logger.info("Deleting reset match", {
|
const onAway = teamPlayerIds(match.away).includes(player.id);
|
||||||
resetMatchId: winner.id,
|
if (onHome && onAway)
|
||||||
currentMatchId: match.id,
|
throw new Error(
|
||||||
reason: "not necessary",
|
"You are on both teams in this match and cannot report or confirm its score"
|
||||||
});
|
);
|
||||||
|
|
||||||
await pbAdmin.deleteMatch(winner.id);
|
const homeId = teamId(match.home);
|
||||||
emitServerEvent({
|
const reportedByHome = match.reported_by_team === homeId;
|
||||||
type: "match",
|
const opposingTeam = reportedByHome ? match.away : match.home;
|
||||||
matchId: match.id,
|
|
||||||
tournamentId: match.tournament.id
|
|
||||||
});
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (winner) {
|
if (!teamPlayerIds(opposingTeam).includes(player.id))
|
||||||
await pbAdmin.updateMatch(winner.id, {
|
throw new Error("Only a player on the other team can confirm this score");
|
||||||
[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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loser) {
|
return finalizeMatch(matchId, {
|
||||||
await pbAdmin.updateMatch(loser.id, {
|
home_cups: match.reported_home_cups,
|
||||||
[loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id,
|
away_cups: match.reported_away_cups,
|
||||||
status:
|
ot_count: match.reported_ot_count ?? 0,
|
||||||
(loser.home_from_lid === match.lid && loser.away) ||
|
});
|
||||||
(loser.away_from_lid === match.lid && loser.home)
|
})
|
||||||
? "ready"
|
);
|
||||||
: "tbd",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
emitServerEvent({
|
export const clearMatchReport = createServerFn()
|
||||||
type: "match",
|
.validator(matchIdSchema)
|
||||||
matchId: match.id,
|
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
tournamentId: match.tournament.id
|
.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;
|
away_seed?: number;
|
||||||
match_type?: MatchType;
|
match_type?: MatchType;
|
||||||
group?: string;
|
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({
|
export const matchInputSchema = z.object({
|
||||||
@@ -52,12 +57,17 @@ export const matchInputSchema = z.object({
|
|||||||
is_losers_bracket: z.boolean().optional().default(false),
|
is_losers_bracket: z.boolean().optional().default(false),
|
||||||
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
|
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
|
||||||
tournament: z.string().min(1),
|
tournament: z.string().min(1),
|
||||||
home: z.string().min(1).optional(),
|
home: z.string().min(1).nullable().optional(),
|
||||||
away: z.string().min(1).optional(),
|
away: z.string().min(1).nullable().optional(),
|
||||||
home_seed: z.number().int().min(1).optional(),
|
home_seed: z.number().int().min(1).optional(),
|
||||||
away_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(),
|
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
|
||||||
group: z.string().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>;
|
export type MatchInput = z.infer<typeof matchInputSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Stack, Text, TextInput, Box, Paper, Group, Divider, Center, ActionIcon, Badge } from "@mantine/core";
|
import { Stack, Text, TextInput, Box, Paper, Group, Divider, Center, ActionIcon, Badge } from "@mantine/core";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon } from "@phosphor-icons/react";
|
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon } from "@phosphor-icons/react";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
import { useAllPlayerStats } from "../queries";
|
import { useAllPlayerStats } from "../queries";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
@@ -8,6 +9,7 @@ import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
|
|||||||
import PlayerAvatar from "@/components/player-avatar";
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
|
||||||
const LeagueHeadToHead = () => {
|
const LeagueHeadToHead = () => {
|
||||||
|
const { t } = useLingui();
|
||||||
const [player1Id, setPlayer1Id] = useState<string | null>(null);
|
const [player1Id, setPlayer1Id] = useState<string | null>(null);
|
||||||
const [player2Id, setPlayer2Id] = useState<string | null>(null);
|
const [player2Id, setPlayer2Id] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -112,7 +114,7 @@ const LeagueHeadToHead = () => {
|
|||||||
<Stack gap={4} align="center">
|
<Stack gap={4} align="center">
|
||||||
<PlayerAvatar size={36} disableFullscreen />
|
<PlayerAvatar size={36} disableFullscreen />
|
||||||
<Text size="xs" c="dimmed" fw={500}>
|
<Text size="xs" c="dimmed" fw={500}>
|
||||||
Player 1
|
<Trans>Player 1</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -120,7 +122,7 @@ const LeagueHeadToHead = () => {
|
|||||||
|
|
||||||
<Center>
|
<Center>
|
||||||
<Text size="xl" fw={700} c="dimmed">
|
<Text size="xl" fw={700} c="dimmed">
|
||||||
VS
|
<Trans>VS</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
|
|
||||||
@@ -168,7 +170,7 @@ const LeagueHeadToHead = () => {
|
|||||||
<Stack gap={4} align="center">
|
<Stack gap={4} align="center">
|
||||||
<PlayerAvatar size={36} disableFullscreen />
|
<PlayerAvatar size={36} disableFullscreen />
|
||||||
<Text size="xs" c="dimmed" fw={500}>
|
<Text size="xs" c="dimmed" fw={500}>
|
||||||
Player 2
|
<Trans>Player 2</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -183,8 +185,8 @@ const LeagueHeadToHead = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
styles={{ label: { textTransform: "none" } }}
|
styles={{ label: { textTransform: "none" } }}
|
||||||
>
|
>
|
||||||
{activeStep === 1 && "Select first player"}
|
{activeStep === 1 && <Trans>Select first player</Trans>}
|
||||||
{activeStep === 2 && "Select second player"}
|
{activeStep === 2 && <Trans>Select second player</Trans>}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Group justify="center">
|
<Group justify="center">
|
||||||
@@ -198,7 +200,7 @@ const LeagueHeadToHead = () => {
|
|||||||
}}
|
}}
|
||||||
td="underline"
|
td="underline"
|
||||||
>
|
>
|
||||||
Clear both players
|
<Trans>Clear both players</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
@@ -206,7 +208,7 @@ const LeagueHeadToHead = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search players"
|
placeholder={t`Search players`}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
@@ -218,7 +220,7 @@ const LeagueHeadToHead = () => {
|
|||||||
<Paper withBorder>
|
<Paper withBorder>
|
||||||
{filteredPlayers.length === 0 && (
|
{filteredPlayers.length === 0 && (
|
||||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
{search ? `No players found matching "${search}"` : "No players available"}
|
{search ? t`No players found matching "${search}"` : t`No players available`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -259,7 +261,7 @@ const LeagueHeadToHead = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{player1Id && player2Id && (
|
{player1Id && player2Id && (
|
||||||
<Sheet title="Head to Head" {...h2hSheet.props}>
|
<Sheet title={t`Head to Head`} {...h2hSheet.props}>
|
||||||
<PlayerHeadToHeadSheet
|
<PlayerHeadToHeadSheet
|
||||||
player1Id={player1Id}
|
player1Id={player1Id}
|
||||||
player1Name={player1Name}
|
player1Name={player1Name}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Stack, Text, Group, Box, Divider, Paper } from "@mantine/core";
|
|||||||
import { usePlayerHeadToHead } from "@/features/matches/queries";
|
import { usePlayerHeadToHead } from "@/features/matches/queries";
|
||||||
import { useMemo, useEffect, useState, Suspense } from "react";
|
import { useMemo, useEffect, useState, Suspense } from "react";
|
||||||
import { CrownIcon } from "@phosphor-icons/react";
|
import { CrownIcon } from "@phosphor-icons/react";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
import PlayerHeadToHeadSkeleton from "./player-head-to-head-skeleton";
|
import PlayerHeadToHeadSkeleton from "./player-head-to-head-skeleton";
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ const PlayerHeadToHeadContent = ({
|
|||||||
return (
|
return (
|
||||||
<Stack p="md" gap="md">
|
<Stack p="md" gap="md">
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
Loading...
|
<Trans>Loading...</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -103,7 +104,7 @@ const PlayerHeadToHeadContent = ({
|
|||||||
return (
|
return (
|
||||||
<Stack p="md" gap="md">
|
<Stack p="md" gap="md">
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
These players have not faced each other yet.
|
<Trans>These players have not faced each other yet.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -126,7 +127,7 @@ const PlayerHeadToHeadContent = ({
|
|||||||
{player1Name}
|
{player1Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
vs
|
<Trans>vs</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="lg" fw={700}>
|
<Text size="lg" fw={700}>
|
||||||
{player2Name}
|
{player2Name}
|
||||||
@@ -159,14 +160,14 @@ const PlayerHeadToHeadContent = ({
|
|||||||
<Group justify="center" gap="xs">
|
<Group justify="center" gap="xs">
|
||||||
<CrownIcon size={16} weight="fill" color="gold" />
|
<CrownIcon size={16} weight="fill" color="gold" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{leader} leads the series
|
<Trans>{leader} leads the series</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!leader && totalMatches > 0 && (
|
{!leader && totalMatches > 0 && (
|
||||||
<Text size="xs" c="dimmed" ta="center">
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
Series is tied
|
<Trans>Series is tied</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -174,7 +175,7 @@ const PlayerHeadToHeadContent = ({
|
|||||||
|
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm" fw={600} px="md" mb="xs">
|
<Text size="sm" fw={600} px="md" mb="xs">
|
||||||
Stats Comparison
|
<Trans>Stats Comparison</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Paper withBorder>
|
<Paper withBorder>
|
||||||
@@ -185,15 +186,15 @@ const PlayerHeadToHeadContent = ({
|
|||||||
{stats.player1CupsFor}
|
{stats.player1CupsFor}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
cups
|
<Trans>cups</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
Total Cups
|
<Trans>Total Cups</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
cups
|
<Trans>cups</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{stats.player2CupsFor}
|
{stats.player2CupsFor}
|
||||||
@@ -210,15 +211,15 @@ const PlayerHeadToHeadContent = ({
|
|||||||
: "0.0"}
|
: "0.0"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
avg
|
<Trans>avg</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
Avg Cups/Match
|
<Trans>Avg Cups/Match</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
avg
|
<Trans>avg</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{totalMatches > 0
|
{totalMatches > 0
|
||||||
@@ -237,15 +238,15 @@ const PlayerHeadToHeadContent = ({
|
|||||||
: "0.0"}
|
: "0.0"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
margin
|
<Trans>margin</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
Avg Win Margin
|
<Trans>Avg Win Margin</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
margin
|
<Trans>margin</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{!isNaN(stats.player2AvgMargin)
|
{!isNaN(stats.player2AvgMargin)
|
||||||
@@ -260,7 +261,7 @@ const PlayerHeadToHeadContent = ({
|
|||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm" fw={600} px="md">
|
<Text size="sm" fw={600} px="md">
|
||||||
Match History ({totalMatches})
|
<Trans>Match History ({totalMatches})</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<MatchList matches={matches} hideH2H />
|
<MatchList matches={matches} hideH2H />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
ScrollArea,
|
ScrollArea,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
const PlayerListItemSkeleton = () => {
|
export const PlayerListItemSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
<Box p="md">
|
<Box p="md">
|
||||||
<Group gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
<Group gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback, memo, useRef, useEffect } from "react";
|
import { useState, useMemo, useCallback, memo, useRef, useEffect, useDeferredValue } from "react";
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -23,8 +23,11 @@ import {
|
|||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { PlayerStats } from "../types";
|
import { PlayerStats } from "../types";
|
||||||
import PlayerAvatar from "@/components/player-avatar";
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
|
import InfiniteScroll from "@/components/infinite-scroll";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useAllPlayerStats } from "../queries";
|
import { useAllPlayerStats } from "../queries";
|
||||||
|
import { PlayerListItemSkeleton } from "./player-stats-table-skeleton";
|
||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
|
||||||
type SortKey = keyof PlayerStats | "mmr";
|
type SortKey = keyof PlayerStats | "mmr";
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
@@ -59,6 +62,7 @@ const StatCell = memo(({ label, value }: StatCellProps) => (
|
|||||||
));
|
));
|
||||||
|
|
||||||
const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onUnregisterViewport }: PlayerListItemProps) => {
|
const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onUnregisterViewport }: PlayerListItemProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const avg_cups_against = useMemo(() => stat.total_cups_against / stat.matches || 0, [stat.total_cups_against, stat.matches]);
|
const avg_cups_against = useMemo(() => stat.total_cups_against / stat.matches || 0, [stat.total_cups_against, stat.matches]);
|
||||||
@@ -101,11 +105,11 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU
|
|||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
{stat.matches}
|
{stat.matches}
|
||||||
<Text span fw={800}>M</Text>
|
<Text span fw={800}><Trans>M</Trans></Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
{stat.tournaments}
|
{stat.tournaments}
|
||||||
<Text span fw={800}>T</Text>
|
<Text span fw={800}><Trans>T</Trans></Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -123,16 +127,16 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap='xs' wrap="nowrap">
|
<Group gap='xs' wrap="nowrap">
|
||||||
<StatCell label="MMR" value={mmr.toFixed(1)} />
|
<StatCell label={t`MMR`} value={mmr.toFixed(1)} />
|
||||||
<StatCell label="W" value={stat.wins} />
|
<StatCell label={t`W`} value={stat.wins} />
|
||||||
<StatCell label="L" value={stat.losses} />
|
<StatCell label={t`L`} value={stat.losses} />
|
||||||
<StatCell label="W%" value={`${stat.win_percentage.toFixed(1)}%`} />
|
<StatCell label={t`W%`} value={`${stat.win_percentage.toFixed(1)}%`} />
|
||||||
<StatCell label="AWM" value={stat.margin_of_victory?.toFixed(1) || 0} />
|
<StatCell label={t`AWM`} value={stat.margin_of_victory?.toFixed(1) || 0} />
|
||||||
<StatCell label="ALM" value={stat.margin_of_loss?.toFixed(1) || 0} />
|
<StatCell label={t`ALM`} value={stat.margin_of_loss?.toFixed(1) || 0} />
|
||||||
<StatCell label="AC" value={stat.avg_cups_per_match.toFixed(1)} />
|
<StatCell label={t`AC`} value={stat.avg_cups_per_match.toFixed(1)} />
|
||||||
<StatCell label="ACA" value={avg_cups_against?.toFixed(1) || 0} />
|
<StatCell label={t`ACA`} value={avg_cups_against?.toFixed(1) || 0} />
|
||||||
<StatCell label="CF" value={stat.total_cups_made} />
|
<StatCell label={t`CF`} value={stat.total_cups_made} />
|
||||||
<StatCell label="CA" value={stat.total_cups_against} />
|
<StatCell label={t`CA`} value={stat.total_cups_against} />
|
||||||
</Group>
|
</Group>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -146,10 +150,33 @@ interface PlayerStatsTableProps {
|
|||||||
viewType?: 'all' | 'mainline' | 'regional';
|
viewType?: 'all' | 'mainline' | 'regional';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const calculateMMR = (stat: PlayerStats): number => {
|
||||||
|
if (stat.matches === 0) return 0;
|
||||||
|
|
||||||
|
const winScore = stat.win_percentage;
|
||||||
|
const matchConfidence = Math.min(stat.matches / 15, 1);
|
||||||
|
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
||||||
|
const marginScore = stat.margin_of_victory
|
||||||
|
? Math.min(stat.margin_of_victory * 20, 50)
|
||||||
|
: 0;
|
||||||
|
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
||||||
|
|
||||||
|
const baseMMR =
|
||||||
|
winScore * 0.5 +
|
||||||
|
avgCupsScore * 0.25 +
|
||||||
|
marginScore * 0.15 +
|
||||||
|
volumeBonus * 0.1;
|
||||||
|
|
||||||
|
const finalMMR = baseMMR * matchConfidence;
|
||||||
|
return Math.round(finalMMR * 10) / 10;
|
||||||
|
};
|
||||||
|
|
||||||
const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const { data: playerStats } = useAllPlayerStats(viewType);
|
const { data: playerStats } = useAllPlayerStats(viewType);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||||
key: "mmr" as SortKey,
|
key: "mmr" as SortKey,
|
||||||
direction: "desc",
|
direction: "desc",
|
||||||
@@ -159,10 +186,15 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
const scrollHandlersRef = useRef<Map<HTMLDivElement, (e: Event) => void>>(new Map());
|
const scrollHandlersRef = useRef<Map<HTMLDivElement, (e: Event) => void>>(new Map());
|
||||||
const scrollLeaderRef = useRef<HTMLDivElement | null>(null);
|
const scrollLeaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollTimeoutRef = useRef<number | null>(null);
|
const scrollTimeoutRef = useRef<number | null>(null);
|
||||||
|
const lastScrollLeftRef = useRef(0);
|
||||||
|
|
||||||
const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => {
|
const handleRegisterViewport = useCallback((viewport: HTMLDivElement) => {
|
||||||
viewportsRef.current.add(viewport);
|
viewportsRef.current.add(viewport);
|
||||||
|
|
||||||
|
if (lastScrollLeftRef.current > 0) {
|
||||||
|
viewport.scrollLeft = lastScrollLeftRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
const handleScrollStart = () => {
|
const handleScrollStart = () => {
|
||||||
scrollLeaderRef.current = viewport;
|
scrollLeaderRef.current = viewport;
|
||||||
};
|
};
|
||||||
@@ -179,6 +211,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollLeft = target.scrollLeft;
|
const scrollLeft = target.scrollLeft;
|
||||||
|
lastScrollLeftRef.current = scrollLeft;
|
||||||
|
|
||||||
viewportsRef.current.forEach((vp) => {
|
viewportsRef.current.forEach((vp) => {
|
||||||
if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) {
|
if (vp !== target && Math.abs(vp.scrollLeft - scrollLeft) > 0.5) {
|
||||||
@@ -213,27 +246,6 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calculateMMR = (stat: PlayerStats): number => {
|
|
||||||
if (stat.matches === 0) return 0;
|
|
||||||
|
|
||||||
const winScore = stat.win_percentage;
|
|
||||||
const matchConfidence = Math.min(stat.matches / 15, 1);
|
|
||||||
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
|
|
||||||
const marginScore = stat.margin_of_victory
|
|
||||||
? Math.min(stat.margin_of_victory * 20, 50)
|
|
||||||
: 0;
|
|
||||||
const volumeBonus = Math.min(stat.matches * 0.5, 10);
|
|
||||||
|
|
||||||
const baseMMR =
|
|
||||||
winScore * 0.5 +
|
|
||||||
avgCupsScore * 0.25 +
|
|
||||||
marginScore * 0.15 +
|
|
||||||
volumeBonus * 0.1;
|
|
||||||
|
|
||||||
const finalMMR = baseMMR * matchConfidence;
|
|
||||||
return Math.round(finalMMR * 10) / 10;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statsWithMMR = useMemo(() => {
|
const statsWithMMR = useMemo(() => {
|
||||||
return playerStats.map((stat) => ({
|
return playerStats.map((stat) => ({
|
||||||
...stat,
|
...stat,
|
||||||
@@ -243,7 +255,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
|
|
||||||
const filteredAndSortedStats = useMemo(() => {
|
const filteredAndSortedStats = useMemo(() => {
|
||||||
let filtered = statsWithMMR.filter((stat) =>
|
let filtered = statsWithMMR.filter((stat) =>
|
||||||
stat.player_name.toLowerCase().includes(search.toLowerCase())
|
stat.player_name.toLowerCase().includes(deferredSearch.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
return filtered.sort((a, b) => {
|
||||||
@@ -272,7 +284,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [statsWithMMR, search, sortConfig]);
|
}, [statsWithMMR, deferredSearch, sortConfig]);
|
||||||
|
|
||||||
const handlePlayerClick = useCallback((playerId: string) => {
|
const handlePlayerClick = useCallback((playerId: string) => {
|
||||||
navigate({ to: `/profile/${playerId}` });
|
navigate({ to: `/profile/${playerId}` });
|
||||||
@@ -301,7 +313,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
<ChartBarIcon size={32} />
|
<ChartBarIcon size={32} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Title order={3} c="dimmed">
|
<Title order={3} c="dimmed">
|
||||||
No Stats Available
|
<Trans>No Stats Available</Trans>
|
||||||
</Title>
|
</Title>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -311,10 +323,10 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
<Container size="100%" px={0}>
|
<Container size="100%" px={0}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text px="md" size="10px" lh={0} c="dimmed">
|
<Text px="md" size="10px" lh={0} c="dimmed">
|
||||||
Showing {filteredAndSortedStats.length} of {playerStats.length} players
|
<Trans>Showing {filteredAndSortedStats.length} of {playerStats.length} players</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search players"
|
placeholder={t`Search players`}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
leftSection={<MagnifyingGlassIcon size={16} />}
|
leftSection={<MagnifyingGlassIcon size={16} />}
|
||||||
@@ -325,13 +337,13 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
<Group px="md" justify="space-between" align="center">
|
<Group px="md" justify="space-between" align="center">
|
||||||
<Group gap="xs" w="100%">
|
<Group gap="xs" w="100%">
|
||||||
<div></div>
|
<div></div>
|
||||||
<Text ml='auto' size="xs" c="dimmed">Sort:</Text>
|
<Text ml='auto' size="xs" c="dimmed"><Trans>Sort:</Trans></Text>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => handleSort("mmr")}
|
onClick={() => handleSort("mmr")}
|
||||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "var(--mantine-color-text)" : "dimmed"}>
|
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "var(--mantine-color-text)" : "dimmed"}>
|
||||||
MMR
|
<Trans>MMR</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{getSortIcon("mmr")}
|
{getSortIcon("mmr")}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@@ -341,7 +353,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "var(--mantine-color-text)" : "dimmed"}>
|
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "var(--mantine-color-text)" : "dimmed"}>
|
||||||
Wins
|
<Trans>Wins</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{getSortIcon("wins")}
|
{getSortIcon("wins")}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
@@ -351,80 +363,80 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "var(--mantine-color-text)" : "dimmed"}>
|
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "var(--mantine-color-text)" : "dimmed"}>
|
||||||
Matches
|
<Trans>Matches</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
{getSortIcon("matches")}
|
{getSortIcon("matches")}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Popover position="bottom-end" withArrow shadow="md">
|
<Popover position="bottom-end" withArrow shadow="md">
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon variant="subtle" size="sm" aria-label="Stat abbreviations and MMR info">
|
<ActionIcon variant="subtle" size="sm" aria-label={t`Stat abbreviations and MMR info`}>
|
||||||
<InfoIcon size={14} />
|
<InfoIcon size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<Box maw={280}>
|
<Box maw={280}>
|
||||||
<Text size="sm" fw={500} mb="xs">
|
<Text size="sm" fw={500} mb="xs">
|
||||||
Stat Abbreviations:
|
<Trans>Stat Abbreviations:</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>M:</strong> Matches
|
<Trans>• <strong>M:</strong> Matches</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>T:</strong> Tournaments
|
<Trans>• <strong>T:</strong> Tournaments</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>MMR:</strong> Matchmaking Rating
|
<Trans>• <strong>MMR:</strong> Matchmaking Rating</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>W:</strong> Wins
|
<Trans>• <strong>W:</strong> Wins</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>L:</strong> Losses
|
<Trans>• <strong>L:</strong> Losses</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>W%:</strong> Win Percentage
|
<Trans>• <strong>W%:</strong> Win Percentage</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>AWM:</strong> Average Win Margin
|
<Trans>• <strong>AWM:</strong> Average Win Margin</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>ALM:</strong> Average Loss Margin
|
<Trans>• <strong>ALM:</strong> Average Loss Margin</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>AC:</strong> Average Cups Per Match
|
<Trans>• <strong>AC:</strong> Average Cups Per Match</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>ACA:</strong> Average Cups Against
|
<Trans>• <strong>ACA:</strong> Average Cups Against</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>CF:</strong> Cups For
|
<Trans>• <strong>CF:</strong> Cups For</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• <strong>CA:</strong> Cups Against
|
<Trans>• <strong>CA:</strong> Cups Against</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
|
|
||||||
<Text size="sm" fw={500} mb="xs">
|
<Text size="sm" fw={500} mb="xs">
|
||||||
MMR Calculation:
|
<Trans>MMR Calculation:</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• Win Rate (50%)
|
<Trans>• Win Rate (50%)</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• Average Cups/Match (25%)
|
<Trans>• Average Cups/Match (25%)</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• Average Win Margin (15%)
|
<Trans>• Average Win Margin (15%)</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mb={2}>
|
<Text size="xs" mb={2}>
|
||||||
• Match Volume Bonus (10%)
|
<Trans>• Match Volume Bonus (10%)</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
* Confidence penalty applied for players with <15 matches
|
<Trans>* Confidence penalty applied for players with <15 matches</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
<Text size="xs" mt="xs" c="dimmed">
|
||||||
** Not an official rating
|
<Trans>** Not an official rating</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
@@ -433,23 +445,35 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{filteredAndSortedStats.map((stat, index) => (
|
<InfiniteScroll
|
||||||
<Box key={stat.id}>
|
items={filteredAndSortedStats}
|
||||||
<PlayerListItem
|
batchSize={25}
|
||||||
stat={stat}
|
renderItem={(stat, index) => (
|
||||||
onPlayerClick={handlePlayerClick}
|
<Box key={stat.id}>
|
||||||
mmr={stat.mmr}
|
{index > 0 && <Divider />}
|
||||||
onRegisterViewport={handleRegisterViewport}
|
<PlayerListItem
|
||||||
onUnregisterViewport={handleUnregisterViewport}
|
stat={stat}
|
||||||
/>
|
onPlayerClick={handlePlayerClick}
|
||||||
{index < filteredAndSortedStats.length - 1 && <Divider />}
|
mmr={stat.mmr}
|
||||||
</Box>
|
onRegisterViewport={handleRegisterViewport}
|
||||||
))}
|
onUnregisterViewport={handleUnregisterViewport}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
loader={
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<PlayerListItemSkeleton />
|
||||||
|
<Divider />
|
||||||
|
<PlayerListItemSkeleton />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{filteredAndSortedStats.length === 0 && search && (
|
{filteredAndSortedStats.length === 0 && search && (
|
||||||
<Text ta="center" c="dimmed" py="xl">
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
No players found matching "{search}"
|
<Trans>No players found matching "{search}"</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { Trans, Plural, useLingui } from "@lingui/react/macro";
|
||||||
import { Player } from "../types";
|
import { Player } from "../types";
|
||||||
import { usePlayersActivity } from "../queries";
|
import { usePlayersActivity } from "../queries";
|
||||||
|
|
||||||
@@ -16,18 +17,20 @@ interface PlayerActivityItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => {
|
const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
const playerName = player.first_name && player.last_name
|
const playerName = player.first_name && player.last_name
|
||||||
? `${player.first_name} ${player.last_name}`
|
? `${player.first_name} ${player.last_name}`
|
||||||
: player.first_name || player.last_name || "Unknown Player";
|
: player.first_name || player.last_name || t`Unknown Player`;
|
||||||
|
|
||||||
const formatDate = (dateStr?: string) => {
|
const formatDate = (dateStr?: string) => {
|
||||||
if (!dateStr) return "Never";
|
if (!dateStr) return t`Never`;
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString(i18n.locale);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimeSince = (dateStr?: string) => {
|
const getTimeSince = (dateStr?: string) => {
|
||||||
if (!dateStr) return "Never active";
|
if (!dateStr) return t`Never active`;
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - date.getTime();
|
const diffMs = now.getTime() - date.getTime();
|
||||||
@@ -35,10 +38,10 @@ const PlayerActivityItem = memo(({ player }: PlayerActivityItemProps) => {
|
|||||||
const diffHours = Math.floor(diffMins / 60);
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
if (diffMins < 1) return "Just now";
|
if (diffMins < 1) return t`Just now`;
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
if (diffMins < 60) return t`${diffMins}m ago`;
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
if (diffHours < 24) return t`${diffHours}h ago`;
|
||||||
if (diffDays < 30) return `${diffDays}d ago`;
|
if (diffDays < 30) return t`${diffDays}d ago`;
|
||||||
return formatDate(dateStr);
|
return formatDate(dateStr);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ export const PlayersActivityTable = () => {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group px="md" justify="space-between" align="center">
|
<Group px="md" justify="space-between" align="center">
|
||||||
<Text size="10px" lh={0} c="dimmed">
|
<Text size="10px" lh={0} c="dimmed">
|
||||||
{players.length} players
|
<Plural value={players.length} one="# player" other="# players" />
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -109,7 +112,7 @@ export const PlayersActivityTable = () => {
|
|||||||
|
|
||||||
{players.length === 0 && (
|
{players.length === 0 && (
|
||||||
<Text ta="center" c="dimmed" py="xl">
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
No player activity found
|
<Trans>No player activity found</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
|
import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
|
||||||
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
|
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
import NameUpdateForm from "./name-form";
|
import NameUpdateForm from "./name-form";
|
||||||
import PlayerAvatar from "@/components/player-avatar";
|
import PlayerAvatar from "@/components/player-avatar";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
@@ -14,6 +15,7 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Header = ({ player }: HeaderProps) => {
|
const Header = ({ player }: HeaderProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
const nameSheet = useSheet();
|
const nameSheet = useSheet();
|
||||||
const h2hSheet = useSheet();
|
const h2hSheet = useSheet();
|
||||||
const { user: authUser } = useAuth();
|
const { user: authUser } = useAuth();
|
||||||
@@ -80,12 +82,12 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Sheet title='Update Name' {...nameSheet.props}>
|
<Sheet title={t`Update Name`} {...nameSheet.props}>
|
||||||
<NameUpdateForm player={player} toggle={nameSheet.toggle} />
|
<NameUpdateForm player={player} toggle={nameSheet.toggle} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
{!owner && authUser && (
|
{!owner && authUser && (
|
||||||
<Sheet title="Head to Head" {...h2hSheet.props}>
|
<Sheet title={t`Head to Head`} {...h2hSheet.props}>
|
||||||
<PlayerHeadToHeadSheet
|
<PlayerHeadToHeadSheet
|
||||||
player1Id={authUser.id}
|
player1Id={authUser.id}
|
||||||
player1Name={authUserName}
|
player1Name={authUserName}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user