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/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts
index 39d6270..2732ee2 100644
--- a/src/app/routeTree.gen.ts
+++ b/src/app/routeTree.gen.ts
@@ -43,6 +43,7 @@ import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_aut
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',
@@ -221,6 +222,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
@@ -252,6 +259,7 @@ export interface FileRoutesByFullPath {
'/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/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
@@ -286,6 +294,7 @@ export interface FileRoutesByTo {
'/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/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
@@ -323,6 +332,7 @@ export interface FileRoutesById {
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_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
@@ -360,6 +370,7 @@ export interface FileRouteTypes {
| '/tournaments/'
| '/tournaments/$id/bracket'
| '/admin/tournaments/'
+ | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -394,6 +405,7 @@ export interface FileRouteTypes {
| '/tournaments'
| '/tournaments/$id/bracket'
| '/admin/tournaments'
+ | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -430,6 +442,7 @@ export interface FileRouteTypes {
| '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/'
+ | '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -695,6 +708,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 +724,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 +736,8 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
+ AuthedAdminTournamentsIdAssignPartnersRoute:
+ AuthedAdminTournamentsIdAssignPartnersRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
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/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/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/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/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/upcoming-tournament/enroll-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx
index 75b5c74..6fccd1d 100644
--- a/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx
@@ -5,13 +5,12 @@ import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
-const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
+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..c8f31f7 100644
--- a/src/features/tournaments/components/upcoming-tournament/index.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/index.tsx
@@ -1,14 +1,14 @@
-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";
@@ -80,12 +80,19 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<>
-
+
+
+ >
+ )}
+
-
-
>
)}
@@ -107,7 +114,10 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
{
isFreeAgent && isEnrollmentOpen && (
-
+
)
}
@@ -130,7 +140,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
Icon={TreeStructureIcon}
disabled
/>
-
+ {tournament.regional === true ? (
+
+ ) : (
+
+ )}
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 (
<>
-