diff --git a/k8s/base/app-deployment.yaml b/k8s/base/app-deployment.yaml
index 455e093..f3fec21 100644
--- a/k8s/base/app-deployment.yaml
+++ b/k8s/base/app-deployment.yaml
@@ -96,6 +96,11 @@ spec:
configMapKeyRef:
name: flxn-config
key: vite_spotify_redirect_uri
+ - name: COOKIE_DOMAIN
+ valueFrom:
+ configMapKeyRef:
+ name: flxn-config
+ key: cookie_domain
resources:
requests:
diff --git a/k8s/overlays/dev/configmap.yaml b/k8s/overlays/dev/configmap.yaml
index 4a45b99..04db0ba 100644
--- a/k8s/overlays/dev/configmap.yaml
+++ b/k8s/overlays/dev/configmap.yaml
@@ -12,3 +12,4 @@ data:
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-dev"
+ cookie_domain: "dev.flexxon.app"
diff --git a/k8s/overlays/prod/configmap.yaml b/k8s/overlays/prod/configmap.yaml
index faf5cb5..1aa7fc3 100644
--- a/k8s/overlays/prod/configmap.yaml
+++ b/k8s/overlays/prod/configmap.yaml
@@ -12,3 +12,4 @@ data:
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-prod"
+ cookie_domain: "flexxon.app"
diff --git a/pb_migrations/1771294794_created_groups.js b/pb_migrations/1771294794_created_groups.js
new file mode 100644
index 0000000..b6da40e
--- /dev/null
+++ b/pb_migrations/1771294794_created_groups.js
@@ -0,0 +1,109 @@
+///
+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": false,
+ "collectionId": "pbc_340646327",
+ "hidden": false,
+ "id": "relation3177167065",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "tournament",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1579384326",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "number4113142680",
+ "max": null,
+ "min": null,
+ "name": "order",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_1568971955",
+ "hidden": false,
+ "id": "relation2529305176",
+ "maxSelect": 999,
+ "minSelect": 0,
+ "name": "teams",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "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_3346940990",
+ "indexes": [],
+ "listRule": null,
+ "name": "groups",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_3346940990");
+
+ return app.delete(collection);
+})
diff --git a/pb_migrations/1771294861_updated_tournaments.js b/pb_migrations/1771294861_updated_tournaments.js
new file mode 100644
index 0000000..184c034
--- /dev/null
+++ b/pb_migrations/1771294861_updated_tournaments.js
@@ -0,0 +1,52 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // remove field
+ collection.fields.removeById("select3736761055")
+
+ // add field
+ collection.fields.addAt(13, new Field({
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text3736761055",
+ "max": 0,
+ "min": 0,
+ "name": "format",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ }))
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // add field
+ collection.fields.addAt(13, new Field({
+ "hidden": false,
+ "id": "select3736761055",
+ "maxSelect": 1,
+ "name": "format",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": [
+ "single_elim",
+ "double_elim",
+ "groups",
+ "swiss",
+ "swiss_bracket",
+ "round_robin"
+ ]
+ }))
+
+ // remove field
+ collection.fields.removeById("text3736761055")
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1771294883_updated_tournaments.js b/pb_migrations/1771294883_updated_tournaments.js
new file mode 100644
index 0000000..56627f0
--- /dev/null
+++ b/pb_migrations/1771294883_updated_tournaments.js
@@ -0,0 +1,25 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // add field
+ collection.fields.addAt(14, new Field({
+ "hidden": false,
+ "id": "json118290348",
+ "maxSize": 0,
+ "name": "group_config",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ }))
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // remove field
+ collection.fields.removeById("json118290348")
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1771294898_updated_tournaments.js b/pb_migrations/1771294898_updated_tournaments.js
new file mode 100644
index 0000000..069c1ce
--- /dev/null
+++ b/pb_migrations/1771294898_updated_tournaments.js
@@ -0,0 +1,29 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // add field
+ collection.fields.addAt(15, new Field({
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text2982008523",
+ "max": 0,
+ "min": 0,
+ "name": "phase",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ }))
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_340646327")
+
+ // remove field
+ collection.fields.removeById("text2982008523")
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1771295070_updated_matches.js b/pb_migrations/1771295070_updated_matches.js
new file mode 100644
index 0000000..a78f5ec
--- /dev/null
+++ b/pb_migrations/1771295070_updated_matches.js
@@ -0,0 +1,47 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2541054544")
+
+ // add field
+ collection.fields.addAt(22, new Field({
+ "cascadeDelete": false,
+ "collectionId": "pbc_3346940990",
+ "hidden": false,
+ "id": "relation1841317061",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "group",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ }))
+
+ // add field
+ collection.fields.addAt(23, new Field({
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text3987859035",
+ "max": 0,
+ "min": 0,
+ "name": "match_type",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ }))
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_2541054544")
+
+ // remove field
+ collection.fields.removeById("relation1841317061")
+
+ // remove field
+ collection.fields.removeById("text3987859035")
+
+ return app.save(collection)
+})
diff --git a/public/site.webmanifest b/public/site.webmanifest
index 5b6c80f..642a4c5 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -1,17 +1,28 @@
{
- "name": "FLXN IX",
"short_name": "FLXN",
+ "name": "FLXN",
+ "description": "Register for FLXN and view FLXN stats",
"icons": [
{
"src": "/favicon.png",
+ "type": "image/png",
"sizes": "192x192",
- "type": "image/png"
+ "purpose": "any maskable"
},
{
"src": "/favicon.png",
+ "type": "image/png",
"sizes": "512x512",
- "type": "image/png"
+ "purpose": "any maskable"
}
],
- "display": "standalone"
+ "start_url": "/",
+ "display": "standalone",
+ "theme_color": "#1e293b",
+ "background_color": "#0f172a",
+ "orientation": "portrait-primary",
+ "scope": "/",
+ "categories": ["games", "social", "beer pong"],
+ "prefer_related_applications": false,
+ "shortcuts": []
}
diff --git a/public/static/img/duncer_cap_badge.png b/public/static/img/duncer_cap_badge.png
new file mode 100644
index 0000000..84e3357
Binary files /dev/null and b/public/static/img/duncer_cap_badge.png differ
diff --git a/public/static/img/flip_cup_badge.png b/public/static/img/flip_cup_badge.png
new file mode 100644
index 0000000..6c2bb93
Binary files /dev/null and b/public/static/img/flip_cup_badge.png differ
diff --git a/public/static/img/gets_around_badge.png b/public/static/img/gets_around_badge.png
new file mode 100644
index 0000000..4be822c
Binary files /dev/null and b/public/static/img/gets_around_badge.png differ
diff --git a/public/static/img/king_of_the_hill_badge.png b/public/static/img/king_of_the_hill_badge.png
new file mode 100644
index 0000000..3586fcd
Binary files /dev/null and b/public/static/img/king_of_the_hill_badge.png differ
diff --git a/public/static/img/one_up_badge.png b/public/static/img/one_up_badge.png
new file mode 100644
index 0000000..38b7613
Binary files /dev/null and b/public/static/img/one_up_badge.png differ
diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts
index 39d6270..6ea2f8c 100644
--- a/src/app/routeTree.gen.ts
+++ b/src/app/routeTree.gen.ts
@@ -38,11 +38,13 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
+import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
+import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
const RefreshSessionRoute = RefreshSessionRouteImport.update({
id: '/refresh-session',
@@ -191,6 +193,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute,
} as any)
+const AuthedTournamentsIdGroupsRoute =
+ AuthedTournamentsIdGroupsRouteImport.update({
+ id: '/tournaments/$id/groups',
+ path: '/tournaments/$id/groups',
+ getParentRoute: () => AuthedRoute,
+ } as any)
const AuthedTournamentsIdBracketRoute =
AuthedTournamentsIdBracketRouteImport.update({
id: '/tournaments/$id/bracket',
@@ -221,6 +229,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute,
} as any)
+const AuthedAdminTournamentsIdAssignPartnersRoute =
+ AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
+ id: '/tournaments/$id/assign-partners',
+ path: '/tournaments/$id/assign-partners',
+ getParentRoute: () => AuthedAdminRoute,
+ } as any)
export interface FileRoutesByFullPath {
'/': typeof AuthedIndexRoute
@@ -251,7 +265,9 @@ export interface FileRoutesByFullPath {
'/admin/': typeof AuthedAdminIndexRoute
'/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
+ '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
+ '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
@@ -285,7 +301,9 @@ export interface FileRoutesByTo {
'/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
+ '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
+ '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
@@ -322,7 +340,9 @@ export interface FileRoutesById {
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
+ '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
+ '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
@@ -359,7 +379,9 @@ export interface FileRouteTypes {
| '/admin/'
| '/tournaments/'
| '/tournaments/$id/bracket'
+ | '/tournaments/$id/groups'
| '/admin/tournaments/'
+ | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -393,7 +415,9 @@ export interface FileRouteTypes {
| '/admin'
| '/tournaments'
| '/tournaments/$id/bracket'
+ | '/tournaments/$id/groups'
| '/admin/tournaments'
+ | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -429,7 +453,9 @@ export interface FileRouteTypes {
| '/_authed/admin/'
| '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket'
+ | '/_authed/tournaments/$id/groups'
| '/_authed/admin/tournaments/'
+ | '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -660,6 +686,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
+ '/_authed/tournaments/$id/groups': {
+ id: '/_authed/tournaments/$id/groups'
+ path: '/tournaments/$id/groups'
+ fullPath: '/tournaments/$id/groups'
+ preLoaderRoute: typeof AuthedTournamentsIdGroupsRouteImport
+ parentRoute: typeof AuthedRoute
+ }
'/_authed/tournaments/$id/bracket': {
id: '/_authed/tournaments/$id/bracket'
path: '/tournaments/$id/bracket'
@@ -695,6 +728,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute
}
+ '/_authed/admin/tournaments/$id/assign-partners': {
+ id: '/_authed/admin/tournaments/$id/assign-partners'
+ path: '/tournaments/$id/assign-partners'
+ fullPath: '/admin/tournaments/$id/assign-partners'
+ preLoaderRoute: typeof AuthedAdminTournamentsIdAssignPartnersRouteImport
+ parentRoute: typeof AuthedAdminRoute
+ }
}
}
@@ -704,6 +744,7 @@ interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
+ AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
@@ -715,6 +756,8 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
+ AuthedAdminTournamentsIdAssignPartnersRoute:
+ AuthedAdminTournamentsIdAssignPartnersRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
@@ -735,6 +778,7 @@ interface AuthedRouteChildren {
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
+ AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
}
const AuthedRouteChildren: AuthedRouteChildren = {
@@ -748,6 +792,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
+ AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
}
const AuthedRouteWithChildren =
diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx
index 03b49f4..c8d541a 100644
--- a/src/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -12,6 +12,7 @@ import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor";
+import { IOSInstallPrompt } from "@/components/ios-install-prompt";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
@@ -47,6 +48,12 @@ export const Route = createRootRouteWithContext<{
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'FLXN IX' },
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
+ { property: 'og:image:width', content: '512' },
+ { property: 'og:image:height', content: '512' },
+ { name: 'mobile-web-app-capable', content: 'yes' },
+ { name: 'apple-mobile-web-app-capable', content: 'yes' },
+ { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
+ { name: 'apple-mobile-web-app-title', content: 'FLXN' },
],
links: [
{
@@ -96,23 +103,35 @@ export const Route = createRootRouteWithContext<{
component: RootComponent,
notFoundComponent: () => ,
beforeLoad: async ({ context, location }) => {
- // Skip auth check for refresh-session route to avoid infinite loops
- if (location.pathname === '/refresh-session') {
- return {};
- }
-
- if (location.pathname === '/login' || location.pathname === '/logout') {
+ const publicRoutes = ['/login', '/logout', '/refresh-session'];
+ if (publicRoutes.some(route => location.pathname.startsWith(route))) {
return {};
}
try {
- // https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
- } catch (error) {
+ } catch (error: any) {
+ if (typeof window !== 'undefined') {
+ const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
+
+ const sessionExists = await doesSessionExist();
+ if (sessionExists) {
+ try {
+ await attemptRefreshingSession();
+ const auth = await ensureServerQueryData(
+ context.queryClient,
+ playerQueries.auth()
+ );
+ return { auth };
+ } catch {
+ return {};
+ }
+ }
+ }
return {};
}
},
@@ -128,6 +147,7 @@ function RootComponent() {
+
diff --git a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
new file mode 100644
index 0000000..a6f02cd
--- /dev/null
+++ b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
@@ -0,0 +1,167 @@
+import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
+import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
+import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
+import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core";
+import { useState } from "react";
+import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
+import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments";
+import TeamAssignmentPreview from "@/features/tournaments/components/team-assignment-preview";
+import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
+import { PlayerInfo } from "@/features/players/types";
+import { useQueryClient } from "@tanstack/react-query";
+
+export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
+ beforeLoad: async ({ context, params }) => {
+ const { queryClient } = context;
+ const tournament = await ensureServerQueryData(
+ queryClient,
+ tournamentQueries.details(params.id)
+ );
+ if (!tournament) throw redirect({ to: "/admin/tournaments" });
+ return { tournament };
+ },
+ loader: ({ context }) => ({
+ header: {
+ withBackButton: true,
+ title: `Manage ${context.tournament.name}`,
+ },
+ }),
+ component: RouteComponent,
+});
+
+interface TeamAssignment {
+ player1: PlayerInfo;
+ player2: PlayerInfo;
+ teamName: string;
+}
+
+function RouteComponent() {
+ const { id } = Route.useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { data: freeAgents } = useFreeAgents(id);
+ const [assignments, setAssignments] = useState(null);
+ const [currentSeed, setCurrentSeed] = useState(undefined);
+
+ const generateMutation = useGenerateRandomTeams();
+ const confirmMutation = useConfirmTeamAssignments();
+
+ const hasOddPlayers = freeAgents.length % 2 !== 0;
+ const hasEnoughPlayers = freeAgents.length >= 2;
+
+ const handleGenerate = () => {
+ generateMutation.mutate(
+ { data: { tournamentId: id, seed: currentSeed } },
+ {
+ onSuccess: (result) => {
+ setAssignments(result.assignments);
+ setCurrentSeed(result.seed);
+ },
+ }
+ );
+ };
+
+ const handleReroll = () => {
+ if (currentSeed === undefined) return;
+ generateMutation.mutate(
+ { data: { tournamentId: id, seed: currentSeed + 1 } },
+ {
+ onSuccess: (result) => {
+ setAssignments(result.assignments);
+ setCurrentSeed(result.seed);
+ },
+ }
+ );
+ };
+
+ const handleConfirm = () => {
+ if (!assignments) return;
+
+ const formattedAssignments = assignments.map((a) => ({
+ player1Id: a.player1.id,
+ player2Id: a.player2.id,
+ teamName: a.teamName,
+ }));
+
+ confirmMutation.mutate(
+ { data: { tournamentId: id, assignments: formattedAssignments } },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: tournamentQueries.details(id).queryKey });
+ queryClient.invalidateQueries({ queryKey: tournamentQueries.free_agents(id).queryKey });
+ navigate({ to: "/admin/tournaments/$id", params: { id } });
+ },
+ }
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {freeAgents.length}
+
+
+ {freeAgents.length === 1 ? "player enrolled" : "players enrolled"}
+
+
+
+ {!hasEnoughPlayers && (
+ }>
+ Need at least 2 players to create teams
+
+ )}
+
+ {hasOddPlayers && (
+ }>
+ Cannot create teams with an odd number of players. Please have one player unenroll.
+
+ )}
+
+ {!assignments && hasEnoughPlayers && !hasOddPlayers && (
+ }
+ onClick={handleGenerate}
+ loading={generateMutation.isPending}
+ >
+ Generate Random Pairings
+
+ )}
+
+
+ {assignments && (
+
+
+
+ Partner Assignments
+
+
+ }
+ onClick={handleReroll}
+ loading={generateMutation.isPending}
+ size="sm"
+ >
+ Re-roll
+
+ }
+ onClick={handleConfirm}
+ loading={confirmMutation.isPending}
+ size="sm"
+ >
+ Confirm & Create Teams
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
index 3de4ccf..0635e6a 100644
--- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx
+++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
@@ -5,7 +5,9 @@ import {
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament";
-import { Container } from "@mantine/core";
+import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
+import GroupStageView from "@/features/tournaments/components/group-stage-view";
+import { Container, Stack, Divider, Title } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
@@ -43,6 +45,20 @@ function RouteComponent() {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
+ const hasGroupStage = useMemo(() => {
+ return tournament.matches?.some((match) => match.round === -1) || false;
+ }, [tournament.matches]);
+
+ const hasKnockout = useMemo(() => {
+ return tournament.matches?.some((match) => match.round !== -1) || false;
+ }, [tournament.matches]);
+
+ const knockoutBracketPopulated = useMemo(() => {
+ return tournament.matches?.some((match) =>
+ match.round === 0 && match.lid >= 0 && (match.home || match.away)
+ ) || false;
+ }, [tournament.matches]);
+
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
@@ -52,6 +68,7 @@ function RouteComponent() {
const losersMap = new Map();
tournament.matches
+ .filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
@@ -79,15 +96,49 @@ function RouteComponent() {
return (
- { isAdmin && }
+ { isAdmin && !tournament.regional && }
{tournament.matches?.length ? (
-
+ hasGroupStage && hasKnockout ? (
+
+
+
+
+
Knockout Bracket
+
+
+
+ ) : hasGroupStage ? (
+
+ ) : (
+
+ )
) : (
-
+ tournament.regional === true ? (
+
+ ) : (
+
+ )
)}
);
diff --git a/src/app/routes/_authed/tournaments/$id.bracket.tsx b/src/app/routes/_authed/tournaments/$id.bracket.tsx
index 094c39d..38a18bc 100644
--- a/src/app/routes/_authed/tournaments/$id.bracket.tsx
+++ b/src/app/routes/_authed/tournaments/$id.bracket.tsx
@@ -4,7 +4,6 @@ import {
useTournament,
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
-import SeedTournament from "@/features/tournaments/components/seed-tournament";
import { Container } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
@@ -18,7 +17,7 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
queryClient,
tournamentQueries.details(params.id)
);
- if (!tournament) throw redirect({ to: "/admin/tournaments" });
+ if (!tournament) throw redirect({ to: "/tournaments" });
return {
tournament,
};
@@ -26,7 +25,6 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
- showSpotifyPanel: true,
header: {
withBackButton: true,
title: `${context.tournament.name}`,
@@ -48,6 +46,7 @@ function RouteComponent() {
const losersMap = new Map();
tournament.matches
+ .filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
@@ -75,7 +74,7 @@ function RouteComponent() {
return (
-
+
);
}
diff --git a/src/app/routes/_authed/tournaments/$id.groups.tsx b/src/app/routes/_authed/tournaments/$id.groups.tsx
new file mode 100644
index 0000000..f0ea172
--- /dev/null
+++ b/src/app/routes/_authed/tournaments/$id.groups.tsx
@@ -0,0 +1,46 @@
+import { createFileRoute, redirect } from "@tanstack/react-router";
+import {
+ tournamentQueries,
+ useTournament,
+} from "@/features/tournaments/queries";
+import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
+import GroupStageView from "@/features/tournaments/components/group-stage-view";
+import { Container } from "@mantine/core";
+
+export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
+ beforeLoad: async ({ context, params }) => {
+ const { queryClient } = context;
+ const tournament = await ensureServerQueryData(
+ queryClient,
+ tournamentQueries.details(params.id)
+ );
+ if (!tournament) throw redirect({ to: "/tournaments" });
+ return {
+ tournament,
+ };
+ },
+ loader: ({ context }) => ({
+ fullWidth: true,
+ withPadding: false,
+ header: {
+ withBackButton: true,
+ title: `${context.tournament.name}`,
+ },
+ }),
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ const { id } = Route.useParams();
+ const { data: tournament } = useTournament(id);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx
new file mode 100644
index 0000000..7d4a140
--- /dev/null
+++ b/src/components/ios-install-prompt.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useState } from 'react'
+import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
+import { DownloadIcon, XIcon } from '@phosphor-icons/react'
+
+export function IOSInstallPrompt() {
+ const [show, setShow] = useState(false)
+ const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
+
+ useEffect(() => {
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
+ const isAndroid = /Android/.test(navigator.userAgent)
+
+ const isInStandaloneMode =
+ window.matchMedia('(display-mode: standalone)').matches ||
+ ('standalone' in window.navigator && (window.navigator as any).standalone)
+
+ const hasBeenDismissed = localStorage.getItem('pwa-install-prompt-dismissed') === 'true'
+
+ if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
+ setPlatform(isIOS ? 'ios' : 'android')
+ const timer = setTimeout(() => setShow(true), 3000)
+ return () => clearTimeout(timer)
+ }
+ }, [])
+
+ const handleDismiss = () => {
+ localStorage.setItem('pwa-install-prompt-dismissed', 'true')
+ setShow(false)
+ }
+
+ if (!show || !platform) return null
+
+ const instructions = platform === 'ios'
+ ? 'Tap Share → Add to Home Screen'
+ : 'Tap Menu (⋮) → Add to Home screen'
+
+ return (
+
+
+
+
+
+
+
+ Please install FLXN • This will save me Twilio credits as you won't be signed out!
+
+
+ {instructions}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/session-monitor.tsx b/src/components/session-monitor.tsx
index ee4e3ec..9fc8963 100644
--- a/src/components/session-monitor.tsx
+++ b/src/components/session-monitor.tsx
@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react';
+import { useNavigate } from '@tanstack/react-router';
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from '@/lib/supertokens';
export function SessionMonitor() {
+ const navigate = useNavigate();
const lastRefreshTimeRef = useRef(0);
const REFRESH_COOLDOWN = 30 * 1000;
@@ -49,12 +51,14 @@ export function SessionMonitor() {
}
};
+ handleVisibilityChange();
+
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
- }, []);
+ }, [navigate]);
return null;
}
diff --git a/src/components/team-avatar.tsx b/src/components/team-avatar.tsx
index a639936..4a69d39 100644
--- a/src/components/team-avatar.tsx
+++ b/src/components/team-avatar.tsx
@@ -30,17 +30,7 @@ const TeamAvatar = ({
const hasNoLogo = !team.logo;
const hasTwoPlayers = team.players?.length === 2;
- let shouldShowPlayerAvatars = false;
-
- if (isRegional !== undefined) {
- shouldShowPlayerAvatars = isRegional && hasTwoPlayers && hasNoLogo;
- } else {
- const tournaments = (team as any).tournaments;
- const hasTournaments = tournaments && tournaments.length > 0;
- const allTournamentsAreRegional = hasTournaments && tournaments.every((t: any) => t.regional === true);
-
- shouldShowPlayerAvatars = hasTwoPlayers && hasNoLogo && (allTournamentsAreRegional || !hasTournaments);
- }
+ const shouldShowPlayerAvatars = isRegional === true && hasTwoPlayers && hasNoLogo;
if (shouldShowPlayerAvatars && team.players?.length === 2) {
const playerSize = size * 0.6;
diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx
index 309a531..2046094 100644
--- a/src/features/bracket/components/bracket-view.tsx
+++ b/src/features/bracket/components/bracket-view.tsx
@@ -7,10 +7,14 @@ import { Match } from "@/features/matches/types";
interface BracketViewProps {
bracket: BracketData;
- showControls?: boolean
+ showControls?: boolean;
+ groupConfig?: {
+ num_groups: number;
+ advance_per_group: number;
+ };
}
-const BracketView: React.FC = ({ bracket, showControls }) => {
+const BracketView: React.FC = ({ bracket, showControls, groupConfig }) => {
const height = useAppShellHeight();
const orders = useMemo(() => {
const map: Record = {};
@@ -32,14 +36,14 @@ const BracketView: React.FC = ({ bracket, showControls }) => {
Winners Bracket
-
+
{bracket.losers && (
Losers Bracket
-
+
)}
diff --git a/src/features/bracket/components/bracket.tsx b/src/features/bracket/components/bracket.tsx
index a3689f4..e038482 100644
--- a/src/features/bracket/components/bracket.tsx
+++ b/src/features/bracket/components/bracket.tsx
@@ -7,12 +7,17 @@ interface BracketProps {
rounds: Match[][];
orders: Record;
showControls?: boolean;
+ groupConfig?: {
+ num_groups: number;
+ advance_per_group: number;
+ };
}
export const Bracket: React.FC = ({
rounds,
orders,
showControls,
+ groupConfig,
}) => {
const containerRef = useRef(null);
const svgRef = useRef(null);
@@ -132,6 +137,7 @@ export const Bracket: React.FC = ({
match={match}
orders={orders}
showControls={showControls}
+ groupConfig={groupConfig}
/>
)
diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx
index 3695513..97baa5b 100644
--- a/src/features/bracket/components/match-card.tsx
+++ b/src/features/bracket/components/match-card.tsx
@@ -17,16 +17,53 @@ interface MatchCardProps {
match: Match;
orders: Record;
showControls?: boolean;
+ groupConfig?: {
+ num_groups: number;
+ advance_per_group: number;
+ };
}
export const MatchCard: React.FC = ({
match,
orders,
showControls,
+ groupConfig,
}) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback();
+
+ const getGroupLabel = useCallback((seed: number | undefined) => {
+ if (!seed || !groupConfig) return undefined;
+
+ const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+ const numGroups = groupConfig.num_groups;
+ const advancePerGroup = groupConfig.advance_per_group;
+
+ const pairIndex = Math.floor((seed - 1) / 2);
+ const isFirstInPair = (seed - 1) % 2 === 0;
+
+ if (isFirstInPair) {
+ const groupIndex = pairIndex % numGroups;
+ const rankIndex = Math.floor(pairIndex / numGroups);
+
+ const rank = rankIndex + 1;
+ const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
+ const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
+
+ return `${groupName} ${rankSuffix}`;
+ } else {
+ const groupIndex = (pairIndex + 1) % numGroups;
+ const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
+
+ const rank = rankIndex + 1;
+ const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
+ const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
+
+ return `${groupName} ${rankSuffix}`;
+ }
+ }, [groupConfig]);
+
const homeSlot = useMemo(
() => ({
from: orders[match.home_from_lid],
@@ -39,8 +76,9 @@ export const MatchCard: React.FC = ({
match.home_cups !== undefined &&
match.away_cups !== undefined &&
match.home_cups > match.away_cups,
+ groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined,
}),
- [match]
+ [match, getGroupLabel]
);
const awaySlot = useMemo(
() => ({
@@ -54,8 +92,9 @@ export const MatchCard: React.FC = ({
match.away_cups !== undefined &&
match.home_cups !== undefined &&
match.away_cups > match.home_cups,
+ groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined,
}),
- [match]
+ [match, getGroupLabel]
);
const showToolbar = useMemo(
@@ -179,8 +218,11 @@ export const MatchCard: React.FC = ({
data: match.id,
});
- // Play walkout sequence after starting the match
- if (hasWalkoutData && match.home?.name && match.away?.name) {
+ // Skip announcements for regional tournaments
+ const isRegional = match.tournament?.regional === true;
+
+ // Play walkout sequence after starting the match (only for non-regional tournaments)
+ if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;
diff --git a/src/features/bracket/components/match-slot.tsx b/src/features/bracket/components/match-slot.tsx
index ba27457..e584be5 100644
--- a/src/features/bracket/components/match-slot.tsx
+++ b/src/features/bracket/components/match-slot.tsx
@@ -11,6 +11,7 @@ interface MatchSlotProps {
seed?: number;
cups?: number;
isWinner?: boolean;
+ groupLabel?: string;
}
export const MatchSlot: React.FC = ({
@@ -19,7 +20,8 @@ export const MatchSlot: React.FC = ({
team,
seed,
cups,
- isWinner
+ isWinner,
+ groupLabel
}) => (
= ({
{team ? (
<>
- 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
@@ -43,18 +45,22 @@ export const MatchSlot: React.FC = ({
{isWinner && (
)}
>
+ ) : groupLabel ? (
+
+ {groupLabel}
+
) : from ? (
{from_loser ? "Loser" : "Winner"} of Match {from}
diff --git a/src/features/matches/server.ts b/src/features/matches/server.ts
index a230d3f..ab06cf7 100644
--- a/src/features/matches/server.ts
+++ b/src/features/matches/server.ts
@@ -5,7 +5,7 @@ import { logger } from "@/lib/logger";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import brackets from "@/features/bracket/utils";
-import { MatchInput } from "@/features/matches/types";
+import { Match, MatchInput } from "@/features/matches/types";
import { serverEvents } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types";
@@ -164,6 +164,189 @@ export const startMatch = createServerFn()
})
);
+export const populateKnockoutBracket = createServerFn()
+ .inputValidator(z.object({
+ tournamentId: z.string(),
+ }))
+ .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
+ .handler(async ({ data: { tournamentId } }) =>
+ toServerResult(async () => {
+ const tournament = await pbAdmin.getTournament(tournamentId);
+ if (!tournament) {
+ throw new Error("Tournament not found");
+ }
+
+ if (!tournament.group_config) {
+ throw new Error("Tournament must have group_config");
+ }
+
+ return await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
+ })
+ );
+
+async function populateKnockoutBracketInternal(tournamentId: string, groupConfig: { num_groups: number; advance_per_group: number }) {
+ logger.info('Populating knockout bracket', { tournamentId });
+
+ const groups = await pbAdmin.getGroupsByTournament(tournamentId);
+ if (!groups || groups.length === 0) {
+ throw new Error("No groups found for tournament");
+ }
+
+ const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
+
+ for (const group of groups) {
+ logger.info('Processing group', {
+ groupId: group.id,
+ groupOrder: group.order,
+ teamsCount: group.teams?.length,
+ teams: group.teams
+ });
+
+ const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
+ const completedMatches = groupMatches.filter(m => m.status === "ended");
+
+ const standings = new Map();
+
+ for (const team of group.teams || []) {
+ // group.teams can be either team objects or just team ID strings
+ const teamId = typeof team === 'string' ? team : team.id;
+ standings.set(teamId, {
+ teamId,
+ wins: 0,
+ losses: 0,
+ cups_for: 0,
+ cups_against: 0,
+ cup_differential: 0,
+ });
+ }
+
+ for (const match of completedMatches) {
+ if (!match.home || !match.away) continue;
+
+ const homeStanding = standings.get(match.home.id);
+ const awayStanding = standings.get(match.away.id);
+
+ if (homeStanding && awayStanding) {
+ homeStanding.cups_for += match.home_cups;
+ homeStanding.cups_against += match.away_cups;
+ awayStanding.cups_for += match.away_cups;
+ awayStanding.cups_against += match.home_cups;
+
+ if (match.home_cups > match.away_cups) {
+ homeStanding.wins++;
+ awayStanding.losses++;
+ } else {
+ awayStanding.wins++;
+ homeStanding.losses++;
+ }
+
+ homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
+ awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
+ }
+ }
+
+ const sortedStandings = Array.from(standings.values()).sort((a, b) => {
+ if (b.wins !== a.wins) return b.wins - a.wins;
+ if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
+ return b.cups_for - a.cups_for;
+ });
+
+ const topTeams = sortedStandings.slice(0, groupConfig.advance_per_group);
+ logger.info('Top teams from group', {
+ groupId: group.id,
+ topTeams: topTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential }))
+ });
+
+ topTeams.forEach((standing, index) => {
+ qualifiedTeams.push({
+ teamId: standing.teamId,
+ groupOrder: group.order,
+ rank: index + 1,
+ });
+ });
+ }
+
+ logger.info('Qualified teams', { qualifiedTeams });
+
+ const orderedTeamIds: string[] = [];
+ const maxRank = groupConfig.advance_per_group;
+
+ for (let rank = 1; rank <= maxRank; rank++) {
+ const teamsAtRank = qualifiedTeams
+ .filter(t => t.rank === rank)
+ .sort((a, b) => a.groupOrder - b.groupOrder);
+ orderedTeamIds.push(...teamsAtRank.map(t => t.teamId));
+ }
+
+ logger.info('Ordered team IDs', { orderedTeamIds });
+
+ const tournament = await pbAdmin.getTournament(tournamentId);
+ const knockoutMatches = (tournament.matches || [])
+ .filter((m: Match) => m.round >= 0 && m.lid >= 0)
+ .sort((a: Match, b: Match) => a.lid - b.lid);
+
+ const seedToTeamId = new Map();
+ orderedTeamIds.forEach((teamId, index) => {
+ seedToTeamId.set(index + 1, teamId);
+ });
+
+ logger.info('Seed to team mapping', {
+ seedToTeamId: Array.from(seedToTeamId.entries()),
+ orderedTeamIds
+ });
+
+ let updatedCount = 0;
+ for (const match of knockoutMatches) {
+ if (match.round === 0) {
+ const updates: any = {};
+
+ if (match.home_seed) {
+ const teamId = seedToTeamId.get(match.home_seed);
+ logger.info('Looking up home seed', {
+ matchId: match.id,
+ home_seed: match.home_seed,
+ teamId
+ });
+ if (teamId) {
+ updates.home = teamId;
+ }
+ }
+
+ if (match.away_seed) {
+ const teamId = seedToTeamId.get(match.away_seed);
+ logger.info('Looking up away seed', {
+ matchId: match.id,
+ away_seed: match.away_seed,
+ teamId
+ });
+ if (teamId) {
+ updates.away = teamId;
+ }
+ }
+
+ if (updates.home && updates.away) {
+ updates.status = "ready";
+ } else if (updates.home || updates.away) {
+ updates.status = "tbd";
+ }
+
+ if (Object.keys(updates).length > 0) {
+ logger.info('Updating match', { matchId: match.id, updates });
+ await pbAdmin.updateMatch(match.id, updates);
+ updatedCount++;
+ }
+ }
+ }
+
+ logger.info('Updated matches', { updatedCount, totalKnockoutMatches: knockoutMatches.length });
+
+ await pbAdmin.updateTournament(tournamentId, {
+ phase: "knockout"
+ });
+
+ logger.info('Knockout bracket populated successfully', { tournamentId });
+}
+
const endMatchSchema = z.object({
matchId: z.string(),
home_cups: z.number(),
@@ -190,19 +373,25 @@ export const endMatch = createServerFn()
ot_count,
});
+ if (match.lid === -1) {
+ serverEvents.emit("match", {
+ type: "match",
+ matchId: match.id,
+ tournamentId: match.tournament.id
+ });
+ return match;
+ }
+
const matchWinner = home_cups > away_cups ? match.home : match.away;
const matchLoser = home_cups < away_cups ? match.home : match.away;
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
- // winner -> where to send match winner to, loser same
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
- // reset match check
if (winner && winner.reset) {
const awayTeamWon = match.away === matchWinner;
if (!awayTeamWon) {
- // Reset match is not necessary
logger.info("Deleting reset match", {
resetMatchId: winner.id,
currentMatchId: match.id,
@@ -214,7 +403,6 @@ export const endMatch = createServerFn()
}
}
- // advance bracket
if (winner) {
await pbAdmin.updateMatch(winner.id, {
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
diff --git a/src/features/matches/types.ts b/src/features/matches/types.ts
index 9100340..7e15b25 100644
--- a/src/features/matches/types.ts
+++ b/src/features/matches/types.ts
@@ -3,6 +3,7 @@ import { TeamInfo, Team } from "../teams/types";
import { TournamentInfo } from "../tournaments/types";
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
+export type MatchType = "group_stage" | "knockout" | "winners" | "losers" | "bracket";
export interface Match {
id: string;
@@ -29,6 +30,8 @@ export interface Match {
updated: string;
home_seed?: number;
away_seed?: number;
+ match_type?: MatchType;
+ group?: string;
}
export const matchInputSchema = z.object({
@@ -53,6 +56,8 @@ export const matchInputSchema = z.object({
away: z.string().min(1).optional(),
home_seed: z.number().int().min(1).optional(),
away_seed: z.number().int().min(1).optional(),
+ match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
+ group: z.string().optional(),
});
export type MatchInput = z.infer;
diff --git a/src/features/teams/components/regional-team-card.tsx b/src/features/teams/components/regional-team-card.tsx
new file mode 100644
index 0000000..8ad7372
--- /dev/null
+++ b/src/features/teams/components/regional-team-card.tsx
@@ -0,0 +1,59 @@
+import {
+ Paper,
+ Text,
+ Group,
+ Box,
+ Title
+} from "@mantine/core";
+import { useTeam } from "../queries";
+import TeamAvatar from "@/components/team-avatar";
+
+interface RegionalTeamCardProps {
+ teamId: string;
+}
+
+const RegionalTeamCard = ({ teamId }: RegionalTeamCardProps) => {
+ const { data: team, error } = useTeam(teamId);
+
+ if (error || !team) {
+ return (
+
+
+ Failed to load team
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {team.name}
+
+
+ {team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')}
+
+
+
+
+ );
+};
+
+export default RegionalTeamCard;
diff --git a/src/features/teams/components/team-list.tsx b/src/features/teams/components/team-list.tsx
index 068062c..622282d 100644
--- a/src/features/teams/components/team-list.tsx
+++ b/src/features/teams/components/team-list.tsx
@@ -49,9 +49,10 @@ interface TeamListProps {
teams: TeamInfo[];
loading?: boolean;
onTeamClick?: (teamId: string) => void;
+ isRegional?: boolean;
}
-const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
+const TeamList = ({ teams, loading = false, onTeamClick, isRegional }: TeamListProps) => {
const navigate = useNavigate();
const handleClick = useCallback(
@@ -92,6 +93,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
team={team}
radius="sm"
size={40}
+ isRegional={isRegional}
/>
}
style={{ cursor: "pointer" }}
diff --git a/src/features/teams/types.ts b/src/features/teams/types.ts
index 4b7cafa..eeed6e5 100644
--- a/src/features/teams/types.ts
+++ b/src/features/teams/types.ts
@@ -51,6 +51,7 @@ export const teamInputSchema = z
song_start: z.number().int().optional(),
song_end: z.number().int().optional(),
song_image_url: z.url("Invalid song image URL").optional(),
+ private: z.boolean().optional(),
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
})
.refine(
diff --git a/src/features/tournaments/components/edit-enrolled-players.tsx b/src/features/tournaments/components/edit-enrolled-players.tsx
new file mode 100644
index 0000000..170f055
--- /dev/null
+++ b/src/features/tournaments/components/edit-enrolled-players.tsx
@@ -0,0 +1,162 @@
+import {
+ Stack,
+ ActionIcon,
+ Text,
+ Group,
+ Loader,
+} from "@mantine/core";
+import { TrashIcon } from "@phosphor-icons/react";
+import { useCallback, memo } from "react";
+import { useFreeAgents } from "../queries";
+import PlayerAvatar from "@/components/player-avatar";
+import { PlayerInfo, Player } from "@/features/players/types";
+import Typeahead, { TypeaheadOption } from "@/components/typeahead";
+import { usePlayers } from "@/features/players/queries";
+import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
+import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
+
+interface EditEnrolledPlayersProps {
+ tournamentId: string;
+}
+
+interface PlayerItemProps {
+ player: PlayerInfo;
+ onRemove: (playerId: string) => void;
+ disabled: boolean;
+}
+
+const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
+ return (
+
+
+
+
+ {player.first_name} {player.last_name}
+
+
+ onRemove(player.id)}
+ disabled={disabled}
+ size="sm"
+ >
+
+
+
+ );
+});
+
+const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
+ const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
+ const { data: allPlayers = [] } = usePlayers();
+
+ const { mutate: removeFreeAgent, isPending: isRemoving } = useAdminUnenrollPlayer();
+ const { mutate: enrollPlayer, isPending: isEnrolling } = useAdminEnrollPlayer();
+
+ const handleRemovePlayer = useCallback(
+ (playerId: string) => {
+ removeFreeAgent({ tournamentId, playerId });
+ },
+ [removeFreeAgent, tournamentId]
+ );
+
+ const handleEnrollPlayer = useCallback(
+ (option: TypeaheadOption) => {
+ enrollPlayer({ tournamentId, playerId: option.data.id });
+ },
+ [enrollPlayer, tournamentId]
+ );
+
+ const enrolledPlayers = freeAgents.map(agent => agent.player).filter((p): p is PlayerInfo => p !== undefined);
+ const enrolledPlayerIds = new Set(enrolledPlayers.map(p => p.id));
+ const hasEnrolledPlayers = enrolledPlayers.length > 0;
+
+ const searchPlayers = async (query: string): Promise[]> => {
+ if (!query.trim()) return [];
+
+ const filtered = allPlayers.filter((player: Player) => {
+ const fullName = `${player.first_name} ${player.last_name}`.toLowerCase();
+ return fullName.includes(query.toLowerCase()) && !enrolledPlayerIds.has(player.id);
+ });
+
+ return filtered.map((player: Player) => ({
+ id: player.id,
+ data: player
+ }));
+ };
+
+ const renderPlayerOption = (option: TypeaheadOption) => {
+ const player = option.data;
+ return (
+
+
+
+ {player.first_name} {player.last_name}
+
+
+ );
+ };
+
+ const formatPlayer = (option: TypeaheadOption) => {
+ return `${option.data.first_name} ${option.data.last_name}`;
+ };
+
+ return (
+
+
+
+ Add Player
+
+
+
+
+
+
+
+ Enrolled Players
+
+
+ {enrolledPlayers.length} players
+
+
+
+ {isLoading ? (
+
+
+
+ ) : !hasEnrolledPlayers ? (
+
+ No players enrolled yet
+
+ ) : (
+
+ {enrolledPlayers.map((player) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default EditEnrolledPlayers;
diff --git a/src/features/tournaments/components/group-match-card.tsx b/src/features/tournaments/components/group-match-card.tsx
new file mode 100644
index 0000000..1b202a3
--- /dev/null
+++ b/src/features/tournaments/components/group-match-card.tsx
@@ -0,0 +1,201 @@
+import React, { useCallback } from "react";
+import { Card, Group, Stack, Text, ActionIcon, Indicator, Flex, Box } from "@mantine/core";
+import { PlayIcon, PencilIcon } from "@phosphor-icons/react";
+import { Match } from "@/features/matches/types";
+import { useSheet } from "@/hooks/use-sheet";
+import Sheet from "@/components/sheet/sheet";
+import { useServerMutation } from "@/lib/tanstack-query/hooks";
+import { endMatch, startMatch } from "@/features/matches/server";
+import { tournamentKeys } from "@/features/tournaments/queries";
+import { useQueryClient } from "@tanstack/react-query";
+import { MatchForm } from "@/features/bracket/components/match-form";
+import TeamAvatar from "@/components/team-avatar";
+
+interface GroupMatchCardProps {
+ match: Match;
+ showControls?: boolean;
+}
+
+const GroupMatchCard: React.FC = ({ match, showControls }) => {
+ const queryClient = useQueryClient();
+ const editSheet = useSheet();
+
+ const isReady = match.status === "ready";
+ const isStarted = match.status === "started";
+ const isEnded = match.status === "ended";
+
+ const homeWon = isEnded && match.home_cups !== undefined && match.away_cups !== undefined && match.home_cups > match.away_cups;
+ const awayWon = isEnded && match.away_cups !== undefined && match.home_cups !== undefined && match.away_cups > match.home_cups;
+
+ const start = useServerMutation({
+ mutationFn: startMatch,
+ successMessage: "Match started!",
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: tournamentKeys.details(match.tournament.id),
+ });
+ },
+ });
+
+ const end = useServerMutation({
+ mutationFn: endMatch,
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: tournamentKeys.details(match.tournament.id),
+ });
+ editSheet.close();
+ },
+ });
+
+ const handleFormSubmit = useCallback(
+ async (data: {
+ home_cups: number;
+ away_cups: number;
+ ot_count: number;
+ }) => {
+ end.mutate({
+ data: {
+ ...data,
+ matchId: match.id,
+ },
+ });
+ },
+ [end, match.id]
+ );
+
+ const handleStartMatch = () => {
+ start.mutate({ data: match.id });
+ };
+
+ const showStartButton = isReady && showControls;
+ const showEditButton = isStarted && showControls;
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {match.home?.name || "TBD"}
+
+
+ {isEnded && match.home_cups !== undefined && (
+
+ {match.home_cups}
+
+ )}
+
+
+
+
+
+
+
+
+ {match.away?.name || "TBD"}
+
+
+ {isEnded && match.away_cups !== undefined && (
+
+ {match.away_cups}
+
+ )}
+
+
+
+
+
+ {showStartButton && (
+
+
+
+ )}
+
+ {showEditButton && (
+
+
+
+ )}
+
+
+ {showControls && (
+
+
+
+ )}
+ >
+ );
+};
+
+export default GroupMatchCard;
diff --git a/src/features/tournaments/components/group-preview.tsx b/src/features/tournaments/components/group-preview.tsx
new file mode 100644
index 0000000..afb944a
--- /dev/null
+++ b/src/features/tournaments/components/group-preview.tsx
@@ -0,0 +1,50 @@
+import { Stack, Text, Card, Group, Box } from "@mantine/core";
+import { TeamInfo } from "@/features/teams/types";
+import TeamAvatar from "@/components/team-avatar";
+
+interface GroupAssignment {
+ groupIndex: number;
+ groupName: string;
+ teams: TeamInfo[];
+}
+
+interface GroupPreviewProps {
+ groups: GroupAssignment[];
+}
+
+const GroupPreview: React.FC = ({ groups }) => {
+ return (
+
+ {groups.map((group) => (
+
+
+
+
+ Group {group.groupName}
+
+
+ ({group.teams.length} teams)
+
+
+
+
+ {group.teams.map((team, index) => (
+
+
+ {index + 1}
+
+
+
+ {team.name}
+
+
+ ))}
+
+
+
+ ))}
+
+ );
+};
+
+export default GroupPreview;
diff --git a/src/features/tournaments/components/group-stage-view.tsx b/src/features/tournaments/components/group-stage-view.tsx
new file mode 100644
index 0000000..b008d61
--- /dev/null
+++ b/src/features/tournaments/components/group-stage-view.tsx
@@ -0,0 +1,401 @@
+import React, { useMemo, useState } from "react";
+import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
+import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
+import { Match } from "@/features/matches/types";
+import { Group } from "../types";
+import GroupMatchCard from "./group-match-card";
+import TeamAvatar from "@/components/team-avatar";
+import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
+import { populateKnockoutBracket } from "@/features/matches/server";
+import { useQueryClient } from "@tanstack/react-query";
+import { tournamentKeys } from "../queries";
+
+interface GroupStageViewProps {
+ groups: Group[];
+ matches: Match[];
+ showControls?: boolean;
+ tournamentId?: string;
+ hasKnockoutBracket?: boolean;
+ isRegional?: boolean;
+}
+
+interface TeamStanding {
+ teamId: string;
+ teamName: string;
+ team: any;
+ wins: number;
+ losses: number;
+ cupsFor: number;
+ cupsAgainst: number;
+ cupDifference: number;
+}
+
+const GroupStageView: React.FC = ({
+ groups,
+ matches,
+ showControls,
+ tournamentId,
+ hasKnockoutBracket,
+ isRegional,
+}) => {
+ const queryClient = useQueryClient();
+ const [expandedTeams, setExpandedTeams] = useState>({});
+
+ const populateKnockoutMutation = useServerMutation({
+ mutationFn: populateKnockoutBracket,
+ successMessage: "Knockout bracket populated successfully!",
+ onSuccess: () => {
+ if (tournamentId) {
+ queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
+ }
+ },
+ });
+
+ const allGroupMatchesCompleted = useMemo(() => {
+ const groupMatches = matches.filter((match) => match.round === -1);
+ if (groupMatches.length === 0) return false;
+ return groupMatches.every((match) => match.status === "ended");
+ }, [matches]);
+
+ const handlePopulateKnockout = () => {
+ if (!tournamentId) return;
+ populateKnockoutMutation.mutate({ data: { tournamentId } });
+ };
+
+ const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
+ if (matches.length <= 1) return matches;
+
+ const ordered: Match[] = [];
+ const remaining = [...matches];
+
+ ordered.push(remaining.shift()!);
+
+ while (remaining.length > 0) {
+ const lastMatch = ordered[ordered.length - 1];
+ const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
+
+ let bestMatchIndex = remaining.findIndex((match) => {
+ const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
+ for (const teamId of currentTeams) {
+ if (lastTeams.has(teamId)) return false;
+ }
+ return true;
+ });
+
+ if (bestMatchIndex === -1) {
+ bestMatchIndex = 0;
+ }
+
+ ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
+ }
+
+ return ordered;
+ };
+
+ const matchesByGroup = useMemo(() => {
+ const map = new Map();
+
+ matches
+ .filter((match) => match.round === -1 && match.group)
+ .forEach((match) => {
+ if (!map.has(match.group!)) {
+ map.set(match.group!, []);
+ }
+ map.get(match.group!)!.push(match);
+ });
+
+ map.forEach((groupMatches, groupId) => {
+ map.set(groupId, orderMatchesWithSpacing(groupMatches));
+ });
+
+ return map;
+ }, [matches]);
+
+ const sortedGroups = useMemo(() => {
+ return [...groups].sort((a, b) => a.order - b.order);
+ }, [groups]);
+
+ const toggleTeams = (groupId: string) => {
+ setExpandedTeams((prev) => ({
+ ...prev,
+ [groupId]: !prev[groupId],
+ }));
+ };
+
+ const getTeamStandings = (groupId: string, teams: any[]): TeamStanding[] => {
+ const groupMatches = matchesByGroup.get(groupId) || [];
+ const standings: Map = new Map();
+
+ teams.forEach((team) => {
+ standings.set(team.id, {
+ teamId: team.id,
+ teamName: team.name,
+ team: team,
+ wins: 0,
+ losses: 0,
+ cupsFor: 0,
+ cupsAgainst: 0,
+ cupDifference: 0,
+ });
+ });
+
+ const completedMatches = groupMatches.filter((match) => match.status === "ended");
+
+ completedMatches.forEach((match) => {
+ const homeId = match.home?.id;
+ const awayId = match.away?.id;
+
+ if (!homeId || !awayId) return;
+
+ const homeStanding = standings.get(homeId);
+ const awayStanding = standings.get(awayId);
+
+ if (!homeStanding || !awayStanding) return;
+
+ const homeCups = match.home_cups || 0;
+ const awayCups = match.away_cups || 0;
+
+ homeStanding.cupsFor += homeCups;
+ homeStanding.cupsAgainst += awayCups;
+ awayStanding.cupsFor += awayCups;
+ awayStanding.cupsAgainst += homeCups;
+
+ homeStanding.cupDifference += homeCups - awayCups;
+ awayStanding.cupDifference += awayCups - homeCups;
+
+ if (homeCups > awayCups) {
+ homeStanding.wins++;
+ awayStanding.losses++;
+ } else if (awayCups > homeCups) {
+ awayStanding.wins++;
+ homeStanding.losses++;
+ }
+ });
+
+ const h2hRecords = new Map>();
+
+ completedMatches.forEach((match) => {
+ const homeId = match.home?.id;
+ const awayId = match.away?.id;
+
+ if (!homeId || !awayId) return;
+
+ if (!h2hRecords.has(homeId)) {
+ h2hRecords.set(homeId, new Map());
+ }
+ if (!h2hRecords.has(awayId)) {
+ h2hRecords.set(awayId, new Map());
+ }
+
+ const homeH2H = h2hRecords.get(homeId)!;
+ const awayH2H = h2hRecords.get(awayId)!;
+
+ if (!homeH2H.has(awayId)) {
+ homeH2H.set(awayId, { wins: 0, cupDiff: 0 });
+ }
+ if (!awayH2H.has(homeId)) {
+ awayH2H.set(homeId, { wins: 0, cupDiff: 0 });
+ }
+
+ const homeRecord = homeH2H.get(awayId)!;
+ const awayRecord = awayH2H.get(homeId)!;
+
+ const homeCups = match.home_cups || 0;
+ const awayCups = match.away_cups || 0;
+
+ const cupDiff = homeCups - awayCups;
+ homeRecord.cupDiff += cupDiff;
+ awayRecord.cupDiff -= cupDiff;
+
+ if (homeCups > awayCups) {
+ homeRecord.wins++;
+ } else if (awayCups > homeCups) {
+ awayRecord.wins++;
+ }
+ });
+
+ return Array.from(standings.values()).sort((a, b) => {
+ if (b.wins !== a.wins) return b.wins - a.wins;
+
+ if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
+
+ if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
+
+ const aH2H = h2hRecords.get(a.teamId);
+ const bH2H = h2hRecords.get(b.teamId);
+
+ if (aH2H && bH2H) {
+ const aVsB = aH2H.get(b.teamId);
+ const bVsA = bH2H.get(a.teamId);
+
+ if (aVsB && bVsA) {
+ if (aVsB.wins !== bVsA.wins) return bVsA.wins - aVsB.wins;
+
+ if (aVsB.cupDiff !== -bVsA.cupDiff) return aVsB.cupDiff - (-bVsA.cupDiff);
+ }
+ }
+
+ return a.teamId.localeCompare(b.teamId);
+ });
+ };
+
+ if (sortedGroups.length === 0) {
+ return (
+
+
+ No groups have been created yet
+
+
+ );
+ }
+
+ const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted;
+
+ return (
+
+
+ {showGenerateKnockoutButton && (
+ }>
+
+ All group matches are finished! Populate the knockout bracket to advance qualified teams.
+
+
+
+ )}
+
+
+ {sortedGroups.map((group) => {
+ const groupMatches = matchesByGroup.get(group.id) || [];
+ const completedMatches = groupMatches.filter((m) => m.status === "ended").length;
+ const totalMatches = groupMatches.length;
+
+ return (
+
+
+ Group {group.name}
+
+ ({completedMatches}/{totalMatches})
+
+
+
+ );
+ })}
+
+
+ {sortedGroups.map((group) => {
+ const groupMatches = matchesByGroup.get(group.id) || [];
+ const standings = getTeamStandings(group.id, group.teams || []);
+
+ return (
+
+
+
+ toggleTeams(group.id)}
+ >
+
+ Standings ({standings.length})
+
+
+ {expandedTeams[group.id] ? : }
+
+
+
+
+ {standings.length > 0 ? (
+ standings.map((standing, index) => (
+ 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
+ }}
+ >
+
+ {index + 1}
+
+
+
+ {standing.teamName}
+
+
+
+ {standing.wins}-{standing.losses}
+
+ 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
+ fw={600}
+ miw={30}
+ ta="center"
+ >
+ {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
+
+
+ {standing.cupsFor}/{standing.cupsAgainst}
+
+
+
+ ))
+ ) : (
+
+ No teams assigned
+
+ )}
+
+
+
+
+ {groupMatches.length === 0 ? (
+
+
+ No matches scheduled
+
+
+ ) : (
+
+ {groupMatches.map((match) => (
+
+ ))}
+
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+};
+
+export default GroupStageView;
diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx
index cc06644..4a601dd 100644
--- a/src/features/tournaments/components/manage-tournament.tsx
+++ b/src/features/tournaments/components/manage-tournament.tsx
@@ -9,11 +9,12 @@ import {
TreeStructureIcon,
UsersThreeIcon,
UsersIcon,
+ ShuffleIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
+import EditEnrolledPlayers from "./edit-enrolled-players";
import ListLink from "@/components/list-link";
-import { RichTextEditor } from "@/components/rich-text-editor";
import React from "react";
import EditRules from "./edit-rules";
@@ -61,11 +62,20 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
-
+ {tournament.regional && (
+
+ )}
+ {!tournament.regional && (
+
+ )}
{
-
+ {tournament.regional === true ? (
+
+ ) : (
+
+ )}
>
);
diff --git a/src/features/tournaments/components/profile/index.tsx b/src/features/tournaments/components/profile/index.tsx
index a1b1553..ca38f40 100644
--- a/src/features/tournaments/components/profile/index.tsx
+++ b/src/features/tournaments/components/profile/index.tsx
@@ -27,7 +27,7 @@ const Profile = ({ id }: ProfileProps) => {
{
label: "Teams",
content: <>
-
+
>
}
], [tournament]);
diff --git a/src/features/tournaments/components/setup-group-stage.tsx b/src/features/tournaments/components/setup-group-stage.tsx
new file mode 100644
index 0000000..928ffa7
--- /dev/null
+++ b/src/features/tournaments/components/setup-group-stage.tsx
@@ -0,0 +1,296 @@
+import React, { useState, useMemo } from "react";
+import {
+ Stack,
+ Text,
+ Group,
+ Button,
+ Select,
+ LoadingOverlay,
+ Alert,
+ Title,
+ Divider,
+ Box,
+} from "@mantine/core";
+import { InfoIcon } from "@phosphor-icons/react";
+import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
+import { generateGroupStage } from "../server";
+import { TeamInfo } from "@/features/teams/types";
+import {
+ calculateGroupConfigurations,
+ assignTeamsToGroups,
+ getGroupName,
+ GroupConfigOption,
+} from "../utils/group-config";
+import GroupPreview from "./group-preview";
+import { useQueryClient } from "@tanstack/react-query";
+import { tournamentKeys } from "../queries";
+import brackets from "@/features/bracket/utils";
+import { Bracket } from "@/features/bracket/components/bracket";
+import { Match } from "@/features/matches/types";
+import { generateSingleEliminationBracket } from "../utils/bracket-generator";
+
+interface SetupGroupStageProps {
+ tournamentId: string;
+ teams: TeamInfo[];
+}
+
+const SetupGroupStage: React.FC = ({
+ tournamentId,
+ teams,
+}) => {
+ const queryClient = useQueryClient();
+
+ const [selectedConfigIndex, setSelectedConfigIndex] = useState("0");
+ const [seed, setSeed] = useState(Date.now());
+
+ const configurations = useMemo(() => {
+ try {
+ return calculateGroupConfigurations(teams.length);
+ } catch (error) {
+ return [];
+ }
+ }, [teams.length]);
+
+ const selectedConfig: GroupConfigOption | null = useMemo(() => {
+ const index = parseInt(selectedConfigIndex);
+ return configurations[index] || null;
+ }, [selectedConfigIndex, configurations]);
+
+ const groupAssignments = useMemo(() => {
+ if (!selectedConfig) return [];
+
+ const teamIds = teams.map((t) => t.id);
+ const assignments = assignTeamsToGroups(teamIds, selectedConfig, seed);
+
+ return assignments.map((teamIds, index) => ({
+ groupIndex: index,
+ groupName: getGroupName(index),
+ teams: teamIds.map((id) => teams.find((t) => t.id === id)!).filter(Boolean),
+ teamIds,
+ }));
+ }, [selectedConfig, teams, seed]);
+
+ const knockoutTeamCount = useMemo(() => {
+ if (!selectedConfig) return 0;
+ return selectedConfig.num_groups * selectedConfig.advance_per_group;
+ }, [selectedConfig]);
+
+ const bracketPreview = useMemo(() => {
+ if (!knockoutTeamCount || !selectedConfig) {
+ return null;
+ }
+
+ let bracketTemplate: any;
+ if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
+ bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
+ } else {
+ try {
+ bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
+ } catch (error) {
+ return null;
+ }
+ }
+
+ const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
+ const seedLabels: Record = {};
+
+ const totalTeams = selectedConfig.num_groups * selectedConfig.advance_per_group;
+ let seedIndex = 1;
+
+ for (let i = 0; i < totalTeams / 2; i++) {
+ const group1 = i % selectedConfig.num_groups;
+ const rankIndex1 = Math.floor(i / selectedConfig.num_groups);
+ const rank1 = rankIndex1 + 1;
+ const groupName1 = groupNames[group1] || `Group ${group1 + 1}`;
+ const rankSuffix1 = rank1 === 1 ? '1st' : rank1 === 2 ? '2nd' : rank1 === 3 ? '3rd' : `${rank1}th`;
+ seedLabels[seedIndex++] = `${groupName1} ${rankSuffix1}`;
+
+ const group2 = (i + 1) % selectedConfig.num_groups;
+ const rankIndex2 = selectedConfig.advance_per_group - 1 - rankIndex1;
+ const rank2 = rankIndex2 + 1;
+ const groupName2 = groupNames[group2] || `Group ${group2 + 1}`;
+ const rankSuffix2 = rank2 === 1 ? '1st' : rank2 === 2 ? '2nd' : rank2 === 3 ? '3rd' : `${rank2}th`;
+ seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
+ }
+
+ const ordersMap: Record = {};
+ bracketTemplate.winners.forEach((round: any[]) => {
+ round.forEach((match: any) => {
+ ordersMap[match.lid] = match.order;
+ });
+ });
+
+ const placeholderMatches: Match[][] = bracketTemplate.winners.map((round: any[], roundIndex: number) =>
+ round.map((match: any) => {
+ const matchData: any = {
+ ...match,
+ id: `preview-${match.lid}`,
+ home_from_lid: match.home_from_lid !== null && match.home_from_lid !== undefined ? match.home_from_lid : -1,
+ away_from_lid: match.away_from_lid !== null && match.away_from_lid !== undefined ? match.away_from_lid : -1,
+ home_cups: 0,
+ away_cups: 0,
+ status: "tbd" as const,
+ tournament: { id: "", name: "" },
+ };
+
+ if (roundIndex === 0) {
+ matchData.home = match.home_seed && !match.bye ? { id: `seed-${match.home_seed}`, name: seedLabels[match.home_seed] } : null;
+ matchData.away = match.away_seed ? { id: `seed-${match.away_seed}`, name: seedLabels[match.away_seed] } : null;
+ } else {
+ matchData.home = null;
+ matchData.away = null;
+ }
+
+ return matchData;
+ })
+ );
+
+ return { matches: placeholderMatches, orders: ordersMap };
+ }, [knockoutTeamCount, selectedConfig]);
+
+ const generateGroups = useServerMutation({
+ mutationFn: generateGroupStage,
+ successMessage: "Group stage generated successfully!",
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: tournamentKeys.details(tournamentId),
+ });
+ },
+ });
+
+ const handleGenerateGroups = () => {
+ if (!selectedConfig) return;
+
+ generateGroups.mutate({
+ data: {
+ tournamentId,
+ groupConfig: {
+ num_groups: selectedConfig.num_groups,
+ teams_per_group: selectedConfig.teams_per_group,
+ advance_per_group: selectedConfig.advance_per_group,
+ matches_guaranteed: selectedConfig.matches_guaranteed,
+ seeding_method: selectedConfig.seeding_method,
+ },
+ teamAssignments: groupAssignments.map((g) => ({
+ groupIndex: g.groupIndex,
+ groupName: g.groupName,
+ teamIds: g.teamIds,
+ })),
+ seed,
+ },
+ });
+ };
+
+ const handleShuffle = () => {
+ setSeed(Date.now());
+ };
+
+ if (configurations.length === 0) {
+ return (
+ }>
+ Need at least 4 teams to create a group stage format. Current team count: {teams.length}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Group Stage Setup
+
+
+ {teams.length} teams
+
+
+
+
+
+
+ Group Configuration
+
+
+
+ {selectedConfig && (
+
+
+ {selectedConfig.total_group_matches} total group stage matches
+
+ {selectedConfig.wildcards_needed > 0 && (
+
+ ⚠ {selectedConfig.wildcards_needed} wildcard spot{selectedConfig.wildcards_needed > 1 ? 's' : ''} needed for knockout bracket
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {groupAssignments.length > 0 && (
+
+ )}
+
+ {bracketPreview && knockoutTeamCount > 0 && (
+
+
+
+ Knockout Bracket Preview ({knockoutTeamCount} Teams)
+
+
+ Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
+
+
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default SetupGroupStage;
diff --git a/src/features/tournaments/components/started-tournament/index.tsx b/src/features/tournaments/components/started-tournament/index.tsx
index 6e23dc6..7a3489a 100644
--- a/src/features/tournaments/components/started-tournament/index.tsx
+++ b/src/features/tournaments/components/started-tournament/index.tsx
@@ -4,7 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import ListLink from "@/components/list-link";
-import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
+import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
import TeamListButton from "../upcoming-tournament/team-list-button";
import RulesListButton from "../upcoming-tournament/rules-list-button";
import MatchCard from "@/features/matches/components/match-card";
@@ -37,6 +37,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
return finalsMatch?.status === 'ended';
}, [tournament.matches]);
+ const hasGroupStage = useMemo(() => {
+ return tournament.matches?.some((match) => match.round === -1) || false;
+ }, [tournament.matches]);
+
return (
@@ -83,12 +87,19 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
Icon={UsersIcon}
/>
)}
+ {hasGroupStage && (
+
+ )}
-
+
diff --git a/src/features/tournaments/components/team-assignment-preview.tsx b/src/features/tournaments/components/team-assignment-preview.tsx
new file mode 100644
index 0000000..04a207a
--- /dev/null
+++ b/src/features/tournaments/components/team-assignment-preview.tsx
@@ -0,0 +1,49 @@
+import { Card, Group, Stack, Text, Avatar } from "@mantine/core";
+import { PlayerInfo } from "@/features/players/types";
+import PlayerAvatar from "@/components/player-avatar";
+
+interface TeamAssignment {
+ player1: PlayerInfo;
+ player2: PlayerInfo;
+ teamName: string;
+}
+
+interface TeamAssignmentPreviewProps {
+ assignments: TeamAssignment[];
+}
+
+const TeamAssignmentPreview: React.FC = ({ assignments }) => {
+ return (
+
+ {assignments.map((assignment, index) => (
+
+
+
+ #{index + 1}
+
+
+
+
+
+ {assignment.player1.first_name} {assignment.player1.last_name}
+
+
+
+
+ &
+
+
+
+
+
+ {assignment.player2.first_name} {assignment.player2.last_name}
+
+
+
+
+ ))}
+
+ );
+};
+
+export default TeamAssignmentPreview;
diff --git a/src/features/tournaments/components/tournament-stats.tsx b/src/features/tournaments/components/tournament-stats.tsx
index 332f577..8d3e2a5 100644
--- a/src/features/tournaments/components/tournament-stats.tsx
+++ b/src/features/tournaments/components/tournament-stats.tsx
@@ -12,8 +12,8 @@ import {
Alert,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
-import { CrownIcon, TreeStructureIcon, InfoIcon } from "@phosphor-icons/react";
-import Avatar from "@/components/avatar";
+import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
+import TeamAvatar from "@/components/team-avatar";
import ListLink from "@/components/list-link";
import { Podium } from "./podium";
@@ -33,6 +33,10 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
[nonByeMatches]
);
+ const hasGroupStage = useMemo(() => {
+ return tournament.matches?.some((match) => match.round === -1) || false;
+ }, [tournament.matches]);
+
const sortedTeamStats = useMemo(() => {
return [...(tournament.team_stats || [])].sort((a, b) => {
if (b.wins !== a.wins) {
@@ -68,6 +72,8 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
Results
Sorted by win percentage
{teamStatsWithCalculations.map((stat, index) => {
+ const team = tournament.teams?.find(t => t.id === stat.team_id);
+
return (
{
>
-
+ {team ? (
+
+ ) : (
+
+ )}
@@ -157,12 +167,19 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
return (
- {tournament.regional && (
+ {tournament.regional && !hasGroupStage && (
}>
- Regional tournaments are a work in progress. Some features might not work as expected.
+ Earlier regional formats aren't supported in the app and order of matches or displayed winners may be unreliable.
)}
{!tournament.regional && }
+ {hasGroupStage && (
+
+ )}
{
+const EnrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
const { open, isOpen, toggle } = useSheet();
const { user, phone } = useAuth();
- const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
+ const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent(isRegional);
const handleEnroll = () => {
- console.log('enrolling...')
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
onSuccess: () => {
toggle();
@@ -22,21 +21,31 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
return (
<>
-
+
-
- Enrolling as a free agent adds you to a pool of players looking for teammates.
-
-
- Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
-
-
- Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
-
-
+ {isRegional ? (
+ <>
+
+ Enroll in this regional tournament to be assigned a random partner.
+
+ >
+ ) : (
+ <>
+
+ Enrolling as a free agent adds you to a pool of players looking for teammates.
+
+
+ Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
+
+
+ Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
+
+ >
+ )}
+
diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx
index 7b2283b..804d5a0 100644
--- a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx
@@ -1,13 +1,17 @@
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
-import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
+import { PhoneIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useFreeAgents } from "../../queries";
import UnenrollFreeAgent from "./unenroll-free-agent";
import toast from "@/lib/sonner";
+import { useAuth } from "@/contexts/auth-context";
+import PlayerAvatar from "@/components/player-avatar";
-const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
- tournamentId
+const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }> = ({
+ tournamentId,
+ isRegional
}) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
+ const { user } = useAuth();
const copyToClipboard = async (phone: string) => {
try {
@@ -38,33 +42,66 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
}
};
+ if (isRegional) {
+ return (
+
+
+
+
+
+
+
+ {user?.first_name} {user?.last_name}
+
+
+
+
+ Enrolled
+
+
+
+
+
+
+
+
+ Partners will be randomly assigned when enrollment closes
+
+
+
+
+ );
+ }
+
return (
-
-
-
-
-
- Enrolled as Free Agent
-
-
-
+
+
+ ✓ Enrolled as Free Agent
+
- You're on the free agent list. Other free agents looking for teams:
+ Other players looking for teammates:
{freeAgents.length > 1 ? (
-
+
+
+
+ Free Agents
+
+
+ {freeAgents.length}
+
+
+
{freeAgents
.filter(agent => agent.player)
.map((agent) => (
-
-
-
- {agent.player?.first_name} {agent.player?.last_name}
-
-
+
+
+ {agent.player?.first_name} {agent.player?.last_name}
+
{agent.phone && (
= ({
)}
))}
-
- {freeAgents.length > 1 && (
-
- {freeAgents.length} free agents total
-
- )}
-
+
) : (
-
-
- You're the only free agent so far
-
-
+
+ You're the first free agent!
+
)}
-
+
+
);
};
diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx
new file mode 100644
index 0000000..a0226dc
--- /dev/null
+++ b/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx
@@ -0,0 +1,46 @@
+import ListButton from "@/components/list-button";
+import Sheet from "@/components/sheet/sheet";
+import { useSheet } from "@/hooks/use-sheet";
+import { UserListIcon } from "@phosphor-icons/react";
+import { useMemo } from "react";
+import { useFreeAgents } from "../../queries";
+import { Text } from "@mantine/core";
+import PlayerList from "@/features/players/components/player-list";
+import { Player } from "@/features/players/types";
+
+interface EnrolledPlayersListButtonProps {
+ tournamentId: string;
+}
+
+const EnrolledPlayersListButton: React.FC = ({ tournamentId }) => {
+ const { data: freeAgents } = useFreeAgents(tournamentId);
+ const count = useMemo(() => freeAgents.length, [freeAgents]);
+ const { open, isOpen, toggle } = useSheet();
+
+ const players = useMemo(() =>
+ freeAgents.map(agent => agent.player).filter((player): player is Player => player !== undefined),
+ [freeAgents]
+ );
+
+ return (
+ <>
+
+
+
+ {count === 0 ? (
+
+ No players enrolled yet
+
+ ) : (
+
+ )}
+
+ >
+ );
+};
+
+export default EnrolledPlayersListButton;
diff --git a/src/features/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx
index e9d514a..98767a4 100644
--- a/src/features/tournaments/components/upcoming-tournament/index.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/index.tsx
@@ -1,17 +1,18 @@
-import { Suspense, useCallback, useMemo } from "react";
+import { Suspense, useMemo } from "react";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
-import { Box, Button, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
+import { Box, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
import Countdown from "@/components/countdown";
import ListLink from "@/components/list-link";
-import ListButton from "@/components/list-button";
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
import TeamListButton from "./team-list-button";
+import EnrolledPlayersListButton from "./enrolled-players-list-button";
import Header from "./header";
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
import TeamCard from "@/features/teams/components/team-card";
+import RegionalTeamCard from "@/features/teams/components/regional-team-card";
import UpdateTeam from "./update-team";
import UnenrollTeam from "./unenroll-team";
import { useQueryClient } from "@tanstack/react-query";
@@ -80,34 +81,52 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<>
-
+
+
+ >
+ )}
+
-
-
>
)}
{isUserEnrolled && (
<>
}>
-
+ {tournament.regional === true ? (
+
+ ) : (
+
+ )}
-
- {isEnrollmentOpen && (
-
+ {tournament.regional !== true && (
+ <>
+
+ {isEnrollmentOpen && (
+
+ )}
+ >
)}
>
)}
{
isFreeAgent && isEnrollmentOpen && (
-
+
)
}
@@ -130,7 +149,15 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
Icon={TreeStructureIcon}
disabled
/>
-
+ {tournament.regional === true ? (
+ (tournament.teams && tournament.teams.length > 0) ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
diff --git a/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx
index f2da9fd..9b97fc8 100644
--- a/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/team-list-button.tsx
@@ -8,9 +8,10 @@ import { useMemo } from "react"
interface TeamListButtonProps {
teams: TeamInfo[]
+ isRegional?: boolean
}
-const TeamListButton: React.FC = ({ teams }) => {
+const TeamListButton: React.FC = ({ teams, isRegional }) => {
const count = useMemo(() => teams.length, [teams]);
const { open, isOpen, toggle } = useSheet();
return (
@@ -22,7 +23,7 @@ const TeamListButton: React.FC = ({ teams }) => {
/>
-
+
>
)
diff --git a/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx
index f08065e..3afae1f 100644
--- a/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx
@@ -5,11 +5,11 @@ import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
-const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
+const UnenrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
- const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
+ const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent(isRegional);
const handleUnenroll = () => {
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
onSuccess: () => {
@@ -20,17 +20,21 @@ const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
return (
<>
-