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

@@ -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

@@ -83,4 +83,39 @@ export const getUnenrolledTeams = createServerFn()
.middleware([superTokensAdminFunctionMiddleware])
.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 });
})
);