free agents

This commit is contained in:
yohlo
2025-09-20 20:50:44 -05:00
parent 5e20b94a1f
commit 1027b49258
37 changed files with 817 additions and 128 deletions

View File

@@ -51,6 +51,7 @@ services:
- postgres
environment:
POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens
ACCESS_TOKEN_VALIDITY: 360000
ports:
- "3567:3567"
env_file:

View File

@@ -0,0 +1,85 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1340419796",
"indexes": [],
"listRule": null,
"name": "badges",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1340419796");
return app.delete(collection);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// add field
collection.fields.addAt(5, new Field({
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation2029409178",
"maxSelect": 999,
"minSelect": 0,
"name": "badges",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("relation2029409178")
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// add field
collection.fields.addAt(6, new Field({
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation2813965191",
"maxSelect": 1,
"minSelect": 0,
"name": "featured_badge",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("relation2813965191")
return app.save(collection)
})

View File

@@ -0,0 +1,84 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2551806565",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1146066909",
"max": 0,
"min": 0,
"name": "phone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2929550049",
"indexes": [],
"listRule": null,
"name": "free_agents",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049");
return app.delete(collection);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// add field
collection.fields.addAt(3, new Field({
"cascadeDelete": false,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// remove field
collection.fields.removeById("relation3177167065")
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("relation1584152981")
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(11, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation1584152981",
"maxSelect": 999,
"minSelect": 0,
"name": "free_agents",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
})

View File

@@ -1,7 +1,3 @@
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/carousel/styles.css";
import '@mantine/tiptap/styles.css';
import {
HeadContent,
Navigate,
@@ -18,9 +14,12 @@ import Providers from "@/features/core/components/providers";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader";
import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
@@ -61,6 +60,10 @@ export const Route = createRootRouteWithContext<{
},
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl },
{ rel: 'stylesheet', href: mantineTiptapCssUrl }
],
}),
errorComponent: (props) => {
@@ -131,7 +134,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
<div className="app">{children}</div>
<Scripts />
<ReactQueryDevtools />
</body>
</html>
);

View File

@@ -3,9 +3,13 @@ import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
export const Route = createFileRoute("/_authed/")({
component: Home,
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home />
</Suspense>,
beforeLoad: async ({ context }) => {
const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
@@ -18,6 +22,7 @@ export const Route = createFileRoute("/_authed/")({
title: context.tournament.name || "FLXN"
}
}),
pendingComponent: () => <UpcomingTournamentSkeleton />
});
function Home() {

View File

@@ -7,7 +7,7 @@ export const Route = createFileRoute("/_authed/stats")({
component: Stats,
beforeLoad: async ({ context }) => {
const queryClient = context.queryClient;
await ensureServerQueryData(queryClient, playerQueries.allStats());
ensureServerQueryData(queryClient, playerQueries.allStats());
},
loader: () => ({
withPadding: false,

View File

@@ -1,8 +1,7 @@
import TeamProfile from "@/features/teams/components/team-profile";
import { teamKeys, teamQueries } from "@/features/teams/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchSchema = z.object({

View File

@@ -14,6 +14,7 @@ export function RichTextEditor({
const editor = useEditor({
extensions: [StarterKit],
content: value,
immediatelyRender: false,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},

View File

@@ -63,7 +63,7 @@ const Drawer: React.FC<DrawerProps> = ({
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
<VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent}>
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer">
<Container flex={1} p="md">
<Box
mb="sm"

View File

@@ -14,12 +14,14 @@ interface AuthData {
user: Player | undefined;
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
roles: string[];
phone: string;
}
export const defaultAuthData: AuthData = {
user: undefined,
metadata: { accentColor: "blue", colorScheme: "auto" },
roles: [],
phone: ""
};
export interface AuthContextType extends AuthData {
@@ -59,6 +61,7 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
user: data?.user || defaultAuthData.user,
metadata: data?.metadata || defaultAuthData.metadata,
roles: data?.roles || defaultAuthData.roles,
phone: data?.phone || "",
set,
}),
[data, defaultAuthData]

View File

@@ -107,7 +107,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
</Text>
<Stack gap={1}>
{match.home?.players.map((p) => (
<Text size="xs" fw={600} c="dimmed" ta="right">
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
@@ -163,7 +163,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
</Text>
<Stack gap={1}>
{match.away?.players.map((p) => (
<Text size="xs" fw={600} c="dimmed" ta="right">
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}

View File

@@ -21,11 +21,18 @@ export const fetchMe = createServerFn()
return {
user: result || undefined,
roles: context.roles,
metadata: context.metadata
metadata: context.metadata,
phone: context.phone
};
} catch (error: any) {
logger.info('fetchMe: Session error', error.message);
return { user: undefined, roles: [], metadata: {} };
logger.info("FetchMe: Session error", error)
if (error?.response?.status === 401) {
const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
throw error;
}
}
return { user: undefined, roles: [], metadata: {}, phone: undefined };
}
})
);

View File

@@ -58,15 +58,12 @@ const EmojiBar = ({
return reaction.players.map(p => p.id).includes(user?.id || "");
}, [user?.id]);
// Get emojis the current user has reacted to
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
if (!reactions) return;
// Sort reactions by count (descending)
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
// Group reactions: show first 3, group the rest
const visibleReactions = sortedReactions.slice(0, 3);
const groupedReactions = sortedReactions.slice(3);

View File

@@ -1,4 +1,4 @@
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
@@ -14,7 +14,7 @@ export const reactionQueries = {
};
export const useMatchReactions = (matchId: string) =>
useServerQuery(reactionQueries.match(matchId));
useServerSuspenseQuery(reactionQueries.match(matchId));
export const useToggleMatchReaction = (matchId: string) => {
const queryClient = useQueryClient();

View File

@@ -69,6 +69,7 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
{teams?.map((team) => (
<div key={team.id}>
<ListItem
key={`team-list-${team.id}`}
p="xs"
icon={
<Avatar

View File

@@ -0,0 +1,75 @@
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
const StartedTournamentSkeleton = () => {
return (
<Stack gap="lg">
{/* Header skeleton */}
<Stack px="md">
<Group justify="space-between" align="flex-start">
<Box style={{ flex: 1 }}>
<Skeleton height={32} width="60%" mb="xs" />
<Skeleton height={16} width="40%" />
</Box>
<Skeleton height={60} width={60} radius="md" />
</Group>
</Stack>
{/* Match carousel skeleton */}
<Box>
<Group gap="xs" px="xl">
{Array.from({ length: 2 }).map((_, index) => (
<Card
key={index}
withBorder
radius="lg"
p="lg"
style={{ minWidth: "95%", flex: "0 0 auto" }}
>
<Stack gap="md">
{/* Match header */}
<Group justify="space-between">
<Skeleton height={14} width="30%" />
<Skeleton height={20} width={60} radius="xl" />
</Group>
{/* Teams */}
<Stack gap="sm">
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
<Center>
<Skeleton height={14} width={20} />
</Center>
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
</Stack>
</Stack>
</Card>
))}
</Group>
</Box>
{/* Actions section skeleton */}
<Box>
<Divider />
<Stack gap={0}>
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
</Stack>
</Box>
</Stack>
);
};
export default StartedTournamentSkeleton;

View File

@@ -2,11 +2,23 @@ import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { Text } from "@mantine/core";
import { Stack, Text } from "@mantine/core";
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
const EnrollFreeAgent = () => {
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { user, phone } = useAuth();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
const handleEnroll = () => {
console.log('enrolling...')
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
onSuccess: () => {
toggle();
}
});
}
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
@@ -14,13 +26,19 @@ const EnrollFreeAgent = () => {
</Button>
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Text size="md" mb="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
</Text>
<Text size="sm" mb="md" c='dimmed'>
You will be automatically paired with a partner before the tournament starts, and you will be able to see your new team and set a walkout song in the app.
</Text>
<Button onClick={console.log}>Confirm</Button>
<Stack gap="xs">
<Text size="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
</Text>
<Text size="sm" c='dimmed'>
You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents.
</Text>
<Text size="xs" c="dimmed">
Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot.
</Text>
<Button onClick={handleEnroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>
);

View File

@@ -0,0 +1,95 @@
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
import { useFreeAgents } from "../../queries";
import UnenrollFreeAgent from "./unenroll-free-agent";
import toast from "@/lib/sonner";
const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
tournamentId
}) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
const copyToClipboard = async (phone: string) => {
try {
await navigator.clipboard.writeText(phone);
toast.success("Phone number copied!");
} catch (err) {
toast.success("Failed to copy");
}
};
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<UserIcon size={16} />
<Text size="sm" fw={500}>
Enrolled as Free Agent
</Text>
</Group>
</Group>
<Text size="xs" c="dimmed">
You're on the free agent list. Other free agents looking for teams:
</Text>
{freeAgents.length > 1 ? (
<Card withBorder radius="md" p="sm">
<Stack gap="xs">
{freeAgents
.filter(agent => agent.player)
.map((agent) => (
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{agent.player?.first_name} {agent.player?.last_name}
</Text>
</Box>
{agent.phone && (
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => copyToClipboard(agent.phone!)}
style={{ cursor: 'pointer' }}
>
<PhoneIcon size={12} />
</ActionIcon>
<Text
size="xs"
c="dimmed"
style={{ cursor: 'pointer' }}
onClick={() => copyToClipboard(agent.phone!)}
>
{agent.phone}
</Text>
</Group>
)}
</Group>
))}
{freeAgents.length > 1 && (
<Badge
variant="light"
size="xs"
color="blue"
style={{ alignSelf: 'flex-start', marginTop: '4px' }}
>
{freeAgents.length} free agents total
</Badge>
)}
</Stack>
</Card>
) : (
<Card withBorder radius="md" p="sm">
<Text size="sm" c="dimmed" ta="center">
You're the only free agent so far
</Text>
</Card>
)}
<UnenrollFreeAgent tournamentId={tournamentId} />
</Stack>
);
};
export default EnrolledFreeAgent;

View File

@@ -14,10 +14,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
() => new Date(tournament.start_time),
[tournament.start_time]
);
const teamCount = useMemo(
() => tournament.teams?.length || 0,
[tournament.teams]
);
return (
<Stack align="center" gap={0}>

View File

@@ -15,8 +15,9 @@ import TeamCard from "@/features/teams/components/team-card";
import UpdateTeam from "./update-team";
import UnenrollTeam from "./unenroll-team";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../../queries";
import { tournamentKeys, useFreeAgents } from "../../queries";
import RulesListButton from "./rules-list-button";
import EnrolledFreeAgent from "./enrolled-free-agent";
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
@@ -40,57 +41,79 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
const queryClient = useQueryClient();
const handleSubmit = () => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.current })
}
queryClient.invalidateQueries({ queryKey: tournamentKeys.current });
};
const { data: free_agents } = useFreeAgents(tournament.id);
const isFreeAgent = useMemo(() => !isUserEnrolled && free_agents.find(a => a.player?.id === user?.id), [free_agents, isUserEnrolled]);
return (
<Stack gap="lg">
<Header tournament={tournament} />
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg">
<Stack gap="xs">
<Group mb="sm" gap="xs" align="center">
<UsersIcon size={16} />
<Text size="sm" fw={500}>
Enrollment
</Text>
{isEnrollmentOpen && (
<Box ml="auto">
<Countdown
date={enrollmentDeadline}
label="Time left"
color="yellow"
/>
</Box>
<Stack px="md">
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg">
<Stack gap="xs">
<Group mb="sm" gap="xs" align="center">
<UsersIcon size={16} />
<Text size="sm" fw={500}>
Enrollment
</Text>
{isEnrollmentOpen && (
<Box ml="auto">
<Countdown
date={enrollmentDeadline}
label="Time left"
color="yellow"
/>
</Box>
)}
</Group>
{!isUserEnrolled && !isEnrollmentOpen && (
<Text fw={600} c="dimmed" size="sm">
Enrollment has been closed for this tournament.
</Text>
)}
</Group>
{!isUserEnrolled &&!isEnrollmentOpen && (
<Text fw={600} c="dimmed" size="sm">
Enrollment has been closed for this tournament.
</Text>
)}
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<>
<EnrollTeam
tournamentId={tournament.id}
onSubmit={handleSubmit}
/>
<Divider my={0} label="or" />
<EnrollFreeAgent tournamentId={tournament.id} />
</>
)}
{!isUserEnrolled &&isEnrollmentOpen && (
<>
<EnrollTeam tournamentId={tournament.id} onSubmit={handleSubmit} />
<Divider my={0} label="or" />
<EnrollFreeAgent />
</>
)}
{isUserEnrolled && (
<>
<Suspense fallback={<TeamCardSkeleton />}>
<TeamCard teamId={userTeam.id} />
</Suspense>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{isEnrollmentOpen && (
<UnenrollTeam
tournamentId={tournament.id}
teamId={userTeam.id}
onSubmit={handleSubmit}
/>
)}
</>
)}
{
isUserEnrolled && <>
<Suspense fallback={<TeamCardSkeleton />}>
<TeamCard teamId={userTeam.id} />
</Suspense>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{ isEnrollmentOpen && <UnenrollTeam tournamentId={tournament.id} teamId={userTeam.id} onSubmit={handleSubmit} />}
</>
}
</Stack>
</Card>
{
isFreeAgent && isEnrollmentOpen && (
<EnrolledFreeAgent tournamentId={tournament.id} />
)
}
</Stack>
</Card>
</Stack>
<Box>
<Divider />
@@ -102,10 +125,10 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
/>
)}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
<RulesListButton tournamentId={tournament.id} />
<TeamListButton teams={tournament.teams || []} />
</Box>

View File

@@ -20,6 +20,7 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
extensions: [StarterKit],
content: tournament?.rules || '',
editable: false,
immediatelyRender: false,
});
return (

View File

@@ -0,0 +1,42 @@
import { Box, Card, Divider, Flex, Group, Skeleton, Stack } from "@mantine/core";
const UpcomingTournamentSkeleton = () => {
return (
<Stack gap="lg">
<Flex px="md" justify="center" w="100%">
<Skeleton height={240} width={240} radius="md" />
<Stack justify="space-between" align="flex-start">
<Box style={{ flex: 1 }}>
<Skeleton height={32} mb="xs" />
<Skeleton height={16} />
</Box>
</Stack>
</Flex>
<Stack px="md">
<Skeleton height={14} width="80%" />
<Card withBorder radius="lg" p="lg">
<Group mb="sm" gap="xs" align="center">
<Skeleton height={16} width={16} />
<Skeleton height={14} width="20%" />
<Box ml="auto">
<Skeleton height={20} width={80} radius="sm" />
</Box>
</Group>
</Card>
</Stack>
<Box>
<Divider />
<Stack gap={0}>
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
</Stack>
</Box>
</Stack>
);
};
export default UpcomingTournamentSkeleton;

View File

@@ -0,0 +1,40 @@
import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
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 { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
const handleUnenroll = () => {
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
onSuccess: () => {
toggle();
}
});
}
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
Unenroll
</Button>
<Sheet title="Are you sure?" opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<Text size="md">
This will remove you from the free agent list.
</Text>
<Button onClick={handleUnenroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>
);
};
export default UnenrollFreeAgent;

View File

@@ -0,0 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { enrollFreeAgent } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useEnrollFreeAgent = () => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: { tournamentId: string, playerId: string, phone: string }) => {
return enrollFreeAgent({ data });
},
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
},
successMessage: 'You\'ve been added as a free agent!',
});
};
export default useEnrollFreeAgent;

View File

@@ -0,0 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { unenrollFreeAgent } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useUnenrollFreeAgent = () => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: { tournamentId: string, playerId: string }) => {
return unenrollFreeAgent({ data });
},
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
},
successMessage: 'You\'ve been removed as a free agent.',
});
};
export default useUnenrollFreeAgent;

View File

@@ -1,11 +1,12 @@
import { getCurrentTournament, getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { getCurrentTournament, getFreeAgents, getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
export const tournamentKeys = {
list: ['tournaments', 'list'] as const,
details: (id: string) => ['tournaments', 'details', id] as const,
current: ['tournaments', 'current'] as const,
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const,
free_agents: (id: string) => ['tournaments', 'free_agents', id] as const
};
export const tournamentQueries = {
@@ -24,6 +25,10 @@ export const tournamentQueries = {
unenrolled: (id: string) => ({
queryKey: tournamentKeys.unenrolled(id),
queryFn: () => getUnenrolledTeams({ data: id })
}),
free_agents: (id: string) => ({
queryKey: tournamentKeys.free_agents(id),
queryFn: () => getFreeAgents({ data: id })
})
};
@@ -38,3 +43,6 @@ export const useCurrentTournament = () =>
export const useUnenrolledTeams = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
export const useFreeAgents = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.free_agents(tournamentId));

View File

@@ -84,3 +84,38 @@ export const getUnenrolledTeams = createServerFn()
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
);
export const getFreeAgents = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
);
export const enrollFreeAgent = createServerFn()
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!player) throw new Error("Player not found");
await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId);
logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone });
})
);
export const unenrollFreeAgent = createServerFn()
.validator(z.object({ tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
const player = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!player) throw new Error("Player not found");
await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId);
logger.info('Player unenrolled as free agent', { playerId: player.id });
})
);

View File

@@ -2,12 +2,9 @@ import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Logger } from "@/lib/logger";
import { useAuth } from "@/contexts/auth-context";
import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries";
import { tournamentQueries } from "@/features/tournaments/queries";
import { reactionKeys, reactionQueries } from "@/features/reactions/queries";
let newIdeasAvailable = false;
let newIdeasCallbacks: (() => void)[] = [];
const logger = new Logger('ServerEvents');
type SSEEvent = {
@@ -19,45 +16,19 @@ type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryCli
const eventHandlers: Record<string, EventHandler> = {
"connected": () => {
logger.info("ServerEvents | New Connection");
logger.info("New Connection");
},
"ping": () => {},
"test": (event, queryClient) => {
},
"match": (event, queryClient) => {
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
queryClient.invalidateQueries(tournamentQueries.current())
},
"reaction": (event, queryClient) => {
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions);
}
};
export function getNewIdeasAvailable(): boolean {
return newIdeasAvailable;
}
export function clearNewIdeasAvailable(): void {
newIdeasAvailable = false;
}
export function subscribeToNewIdeas(callback: () => void): () => void {
newIdeasCallbacks.push(callback);
return () => {
const index = newIdeasCallbacks.indexOf(callback);
if (index > -1) {
newIdeasCallbacks.splice(index, 1);
}
};
}
export function useServerEvents() {
const queryClient = useQueryClient();
const { user } = useAuth();

View File

@@ -105,13 +105,11 @@ export function createPlayersService(pb: PocketBase) {
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
try {
// Get the tournament with its enrolled teams
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
fields: "teams",
expand: "teams,teams.players"
});
// Extract player IDs from all enrolled teams
const enrolledPlayerIds: string[] = [];
if (tournament.expand?.teams) {
const teams = Array.isArray(tournament.expand.teams) ? tournament.expand.teams : [tournament.expand.teams];
@@ -125,7 +123,6 @@ export function createPlayersService(pb: PocketBase) {
});
}
// If no players are enrolled, return all players
if (enrolledPlayerIds.length === 0) {
const allPlayers = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name,email",
@@ -133,7 +130,6 @@ export function createPlayersService(pb: PocketBase) {
return allPlayers.map(transformPlayer);
}
// Build filter to exclude enrolled players
const filter = enrolledPlayerIds
.map((playerId: string) => `id != "${playerId}"`)
.join(" && ");
@@ -146,7 +142,6 @@ export function createPlayersService(pb: PocketBase) {
return availablePlayers.map(transformPlayer);
} catch (error) {
console.error("Error getting unenrolled players:", error);
// Fallback to all players if there's an error
const allPlayers = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name,email",
});

View File

@@ -7,8 +7,10 @@ import type {
} from "@/features/tournaments/types";
import type { Team } from "@/features/teams/types";
import PocketBase from "pocketbase";
import { transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
import { getFreeAgents } from "@/features/tournaments/server";
import { PlayerInfo } from "@/features/players/types";
export function createTournamentsService(pb: PocketBase) {
return {
@@ -133,5 +135,33 @@ export function createTournamentsService(pb: PocketBase) {
throw error;
}
},
async enrollFreeAgent(playerId: string, phone: string, tournamentId: string): Promise<void> {
await pb.collection("free_agents").create({
tournament: tournamentId,
player: playerId,
phone: phone
});
},
async unenrollFreeAgent(playerId: string, tournamentId: string): Promise<void> {
const result = await pb.collection("free_agents").getFirstListItem(
`player = "${playerId}" && tournament = "${tournamentId}"`
);
await pb.collection("free_agents").delete(result.id);
},
async getFreeAgents(tournamentId: string): Promise<{ id: string, phone: string, player: PlayerInfo | undefined }[]> {
try {
const free_agents = await pb
.collection("free_agents")
.getFullList({ filter: `tournament = "${tournamentId}"`,
expand: 'player'
});
return free_agents.map(transformFreeAgent);
} catch (error) {
logger.error("PocketBase | Error getting unenrolled teams", error);
throw error;
}
},
};
}

View File

@@ -85,6 +85,22 @@ export function transformPlayer(record: any): Player {
};
}
export function transformFreeAgent(record: any) {
const player = record.expand?.player ? transformPlayerInfo(record.expand.player) : undefined;
const tournaments =
record.expand?.tournaments
?.sort((a: any, b: any) =>
new Date(a.created!) < new Date(b.created!) ? -1 : 0
)
?.map(transformTournamentInfo) ?? [];
return {
id: record.id as string,
phone: record.phone as string,
player
};
}
export function transformTeam(record: any): Team {
const players =
record.expand?.players

View File

@@ -1,15 +1,16 @@
import { getSessionForSSR } from "supertokens-node/custom";
import { ensureSuperTokensBackend } from "../server";
import { logger } from "../";
import SuperTokens from "supertokens-node";
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
ensureSuperTokensBackend();
try {
const session = await getSessionForSSR(request);
if (session.hasToken) {
if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) {
const userId = session.accessTokenPayload?.sub;
if (userId === undefined || session.accessTokenPayload?.sessionHandle === undefined) {
return {
hasToken: true,
needsRefresh: true,
@@ -17,11 +18,15 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
}
}
const user = await SuperTokens.getUser(userId);
return {
hasToken: true,
accessTokenPayload: session.accessTokenPayload,
userId: session.accessTokenPayload?.sub,
userId,
sessionHandle: session.accessTokenPayload?.sessionHandle,
phone: user?.phoneNumbers[0]
};
}

View File

@@ -60,6 +60,7 @@ export const verifySuperTokensSession = async (
userAuthId,
roles,
metadata,
phone: session.phone,
session: {
accessTokenPayload: session.accessTokenPayload,
sessionHandle: session.sessionHandle,
@@ -68,7 +69,7 @@ export const verifySuperTokensSession = async (
};
};
export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }): Promise<any> => {
export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }) => {
const session = await verifySuperTokensSession(request);
if (session.context.session?.tryRefresh) {
@@ -93,6 +94,7 @@ export const getSessionContext = async (request: Request, options?: { isServerFu
userAuthId: session.context.userAuthId,
roles: session.context.roles,
metadata: session.context.metadata,
phone: session.context.phone
};
return context;