upcoming tournament page, minor changes

This commit is contained in:
yohlo
2025-09-09 23:20:19 -05:00
parent c5d69f1a19
commit c74da09bde
29 changed files with 1125 additions and 46 deletions

View File

@@ -35,7 +35,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
);
const showToolbar = useMemo(
() => match.home && match.away,
() => match.home && match.away && onAnnounce,
[match.home, match.away]
);

View File

@@ -0,0 +1,229 @@
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
import { TournamentInput } from "@/features/tournaments/types";
import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-team";
import useUpdateTournament from "../hooks/use-update-tournament";
import toast from "@/lib/sonner";
import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "@/features/tournaments/queries";
import { useCallback } from "react";
import { DateTimePicker } from "@/components/date-time-picker";
interface TournamentFormProps {
close: () => void;
initialValues?: Partial<TournamentInput>;
tournamentId?: string;
}
const TournamentForm = ({
close,
initialValues,
tournamentId,
}: TournamentFormProps) => {
const isEditMode = !!tournamentId;
const config: UseFormInput<TournamentInput> = {
initialValues: {
name: initialValues?.name || "",
location: initialValues?.location || "",
desc: initialValues?.desc || "",
start_time: initialValues?.start_time || "",
enroll_time: initialValues?.enroll_time || "",
end_time: initialValues?.end_time || "",
logo: undefined,
},
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty("Name is required"),
location: isNotEmpty("Location is required"),
start_time: isNotEmpty("Start time is required"),
enroll_time: isNotEmpty("Enrollment time is required"),
},
};
const form = useForm(config);
const queryClient = useQueryClient();
const { mutate: createTournament, isPending: createPending } =
useCreateTournament();
const { mutate: updateTournament, isPending: updatePending } =
useUpdateTournament(tournamentId || "");
const isPending = createPending || updatePending;
const handleSubmit = useCallback(
async (values: TournamentInput) => {
const { logo, ...tournamentData } = values;
const mutation = isEditMode ? updateTournament : createTournament;
const successMessage = isEditMode
? "Tournament updated successfully!"
: "Tournament created successfully!";
const errorMessage = isEditMode
? "Failed to update tournament"
: "Failed to create tournament";
mutation(tournamentData, {
onSuccess: async (tournament) => {
if (logo && tournament) {
try {
const formData = new FormData();
formData.append("tournamentId", tournament.id);
formData.append("logo", logo);
const response = await fetch("/api/tournaments/upload-logo", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to upload logo");
}
const result = await response.json();
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(result.tournament!.id),
});
queryClient.setQueryData(
tournamentKeys.details(result.tournament!.id),
result.tournament
);
toast.success(successMessage);
} catch (error: any) {
const logoErrorMessage = isEditMode
? `Tournament updated but logo upload failed: ${error.message}`
: `Tournament created but logo upload failed: ${error.message}`;
toast.error(logoErrorMessage);
logger.error("Tournament logo upload error", error);
}
} else {
toast.success(successMessage);
}
close();
},
onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`);
logger.error(
`Tournament ${isEditMode ? "update" : "create"} error`,
error
);
},
});
},
[isEditMode, createTournament, updateTournament, queryClient]
);
return (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
cancelText="Cancel"
loading={isPending}
>
<Stack>
<TextInput
label="Name"
withAsterisk
key={form.key("name")}
{...form.getInputProps("name")}
/>
<TextInput
label="Location"
withAsterisk
key={form.key("location")}
{...form.getInputProps("location")}
/>
<Textarea
label="Description"
key={form.key("desc")}
{...form.getInputProps("desc")}
minRows={3}
/>
<FileInput
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"
label={isEditMode ? "Change Logo" : "Logo"}
leftSection={<LinkIcon size={16} />}
{...form.getInputProps("logo")}
/>
<SlidePanelField
key={form.key("start_time")}
{...form.getInputProps("start_time")}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
<SlidePanelField
key={form.key("enroll_time")}
{...form.getInputProps("enroll_time")}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
{isEditMode && (
<SlidePanelField
key={form.key("end_time")}
{...form.getInputProps("end_time")}
Component={DateTimePicker}
title="Select End Date"
label="End Date (Optional)"
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
)}
</Stack>
</SlidePanel>
);
};
export default TournamentForm;

View File

@@ -0,0 +1,77 @@
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
import React, { useState, useCallback, useMemo } from 'react';
interface TeamColorPickerProps {
value: string;
onChange: (value: string) => void;
label?: string;
}
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
value,
onChange,
label = "Select Color"
}) => {
const [customHex, setCustomHex] = useState(value || '');
const isValidHex = useMemo(() => {
const hexRegex = /^#[0-9A-F]{6}$/i;
return hexRegex.test(customHex);
}, [customHex]);
const handleColorChange = useCallback((color: string) => {
setCustomHex(color);
onChange(color);
}, [onChange]);
const handleHexInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const hex = event.currentTarget.value;
setCustomHex(hex);
if (/^#[0-9A-F]{6}$/i.test(hex)) {
onChange(hex);
}
}, [onChange]);
const presetColors = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
'#008000', '#000080', '#800000', '#808000'
];
return (
<Stack gap="md" p="md">
<Text fw={500}>{label}</Text>
<ColorPicker
value={value || '#000000'}
onChange={handleColorChange}
format="hex"
swatches={presetColors}
withPicker={true}
fullWidth
/>
<Group gap="xs" align="flex-end">
<TextInput
style={{ flex: 1 }}
label="Custom Hex Code"
placeholder="#FF0000"
value={customHex}
onChange={handleHexInputChange}
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
/>
{isValidHex && (
<ColorSwatch
color={customHex}
size={36}
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
/>
)}
</Group>
</Stack>
);
};
export default TeamColorPicker;

View File

@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { listPlayers } from '@/features/players/server';
import { useServerSuspenseQuery } from '@/lib/tanstack-query/hooks';
export const playerKeys = {
all: ['players'] as const,
available: (excludedIds: string[] = []) => [...playerKeys.all, 'available', excludedIds] as const,
};
export const playerQueries = {
all: () => ({
queryKey: playerKeys.all,
queryFn: listPlayers
}),
};
export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
const { data: allPlayers } = useServerSuspenseQuery(playerQueries.all());
const availablePlayers = useMemo(() => {
if (!allPlayers) return [];
return allPlayers.filter(player =>
!excludedPlayerIds.includes(player.id) &&
player.first_name &&
player.last_name
);
}, [allPlayers, excludedPlayerIds]);
const playerOptions = useMemo(() =>
availablePlayers.map(player => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`.trim() || 'Unnamed Player'
})),
[availablePlayers]
);
return {
availablePlayers,
playerOptions,
allPlayers
};
};

View File

@@ -0,0 +1,22 @@
import { useNavigate } from "@tanstack/react-router";
import { createTeam } from "@/features/teams/server";
import { TeamInput } from "@/features/teams/types";
import { logger } from "@/lib/logger";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useCreateTeam = () => {
const navigate = useNavigate();
return useServerMutation({
mutationFn: (data: TeamInput) => createTeam({ data }),
onMutate: (data) => {
logger.info('Creating team', data);
},
onSuccess: (team) => {
navigate({ to: '/teams/$id', params: { id: team.id } });
},
successMessage: 'Team created successfully!',
});
};
export default useCreateTeam;

View File

@@ -0,0 +1,17 @@
import { updateTeam } from "@/features/teams/server";
import { TeamUpdateInput } from "@/features/teams/types";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { logger } from "@/lib/logger";
const useUpdateTeam = (teamId: string) => {
return useServerMutation({
mutationFn: (data: TeamUpdateInput) =>
updateTeam({ data: { id: teamId, updates: data } }),
onMutate: (data) => {
logger.info('Updating team', { teamId, updates: Object.keys(data) });
},
successMessage: 'Team updated successfully!',
});
};
export default useUpdateTeam;

View File

@@ -1,8 +1,22 @@
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { superTokensFunctionMiddleware, superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { teamInputSchema, teamUpdateSchema } from "./types";
import { logger } from "@/lib/logger";
export const listTeams = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.listTeams())
);
export const listTeamInfos = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.listTeamInfos())
);
export const getTeam = createServerFn()
.validator(z.string())
@@ -10,3 +24,74 @@ export const getTeam = createServerFn()
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeam(teamId))
);
export const getTeamInfo = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamInfo(teamId))
);
export const createTeam = createServerFn()
.validator(teamInputSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data, context }) =>
toServerResult(async () => {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
// Check if user is trying to create a team with themselves as a player
if (!isAdmin && !data.players.includes(userId)) {
throw new Error("You can only create teams that include yourself as a player");
}
// Additional validation: ensure user is not already on another team
if (!isAdmin) {
const userTeams = await pbAdmin.listTeams();
const existingTeam = userTeams.find(team =>
team.players.some(player => player.id === userId)
);
if (existingTeam) {
throw new Error(`You are already a member of team "${existingTeam.name}"`);
}
}
logger.info("Creating team", { name: data.name, userId, isAdmin });
return pbAdmin.createTeam(data);
})
);
export const updateTeam = createServerFn()
.validator(z.object({
id: z.string(),
updates: teamUpdateSchema
}))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { id, updates }, context }) =>
toServerResult(async () => {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
// Get the team to verify ownership
const team = await pbAdmin.getTeam(id);
if (!team) {
throw new Error("Team not found");
}
// Check if user has permission to update this team
const isPlayerOnTeam = team.players.some(player => player.id === userId);
if (!isAdmin && !isPlayerOnTeam) {
throw new Error("You can only update teams that you are a member of");
}
logger.info("Updating team", { teamId: id, userId, isAdmin });
return pbAdmin.updateTeam(id, updates);
})
);
export const deleteTeam = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.deleteTeam(teamId))
);

View File

@@ -27,6 +27,7 @@ export interface TeamInfo {
name: string;
primary_color: string;
accent_color: string;
logo?: string;
players: PlayerInfo[];
}
@@ -50,6 +51,38 @@ 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(),
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
})
.refine(
(data) => {
if (data.song_start && data.song_end) {
return data.song_end > data.song_start;
}
return true;
},
{ message: "Song end time must be after start time", path: ["song_end"] }
);
export const teamUpdateSchema = z
.object({
name: z.string().min(1, "Team name is required").max(100, "Name too long").optional(),
logo: z.file("Invalid logo").optional(),
primary_color: z
.string()
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
.optional(),
accent_color: z
.string()
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
.optional(),
song_id: z.string().max(255).optional(),
song_name: z.string().max(255).optional(),
song_artist: z.string().max(255).optional(),
song_album: z.string().max(255).optional(),
song_year: z.number().int().optional(),
song_start: z.number().int().optional(),
song_end: z.number().int().optional(),
song_image_url: z.url("Invalid song image URL").optional(),
})
.refine(
(data) => {
@@ -62,4 +95,4 @@ export const teamInputSchema = z
);
export type TeamInput = z.infer<typeof teamInputSchema>;
export type TeamUpdateInput = Partial<TeamInput>;
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;

View File

@@ -0,0 +1,29 @@
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";
const EnrollFreeAgent = () => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
return (
<>
<Button variant="outline" size="sm" onClick={open}>
Enroll As Free Agent
</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>
</Sheet>
</>
);
};
export default EnrollFreeAgent;

View File

@@ -0,0 +1,63 @@
import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { useMemo, useState, useCallback } from "react";
import TeamSelectionView from "./team-selection-view";
const EnrollTeam = () => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
const [selectedTeamId, setSelectedTeamId] = useState<string>();
const [showTeamForm, setShowTeamForm] = useState<boolean>(!hasTeams);
const teamOptions = useMemo(() =>
user?.teams?.map(team => ({
value: team.id,
label: team.name
})) || [],
[user?.teams]
);
const handleBack = useCallback(() => {
setShowTeamForm(false);
setSelectedTeamId(undefined);
}, []);
const handleSelect = useCallback((teamId: string | undefined) => {
setSelectedTeamId(teamId);
setShowTeamForm(true);
}, [])
return (
<>
<Button variant="outline" size="sm" onClick={open}>
Enroll Your Team
</Button>
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
{showTeamForm ? (
<>
<p>Team Form {selectedTeamId === undefined ? "new team" : selectedTeamId}</p>
<Button variant="subtle" color="red" onClick={handleBack}>Back</Button>
</>
) : (
<>
<TeamSelectionView
options={teamOptions}
onSelect={handleSelect}
/>
<Button mt='sm' variant="subtle" color='red' size="sm" onClick={toggle}>
Cancel
</Button>
</>
)}
</Sheet>
</>
);
};
export default EnrollTeam;

View File

@@ -0,0 +1,57 @@
import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core';
import { PlusIcon } from '@phosphor-icons/react';
import React, { useMemo, useState } from 'react';
interface TeamSelectionViewProps {
options: ComboboxItem[];
onSelect: (teamId: string | undefined) => void;
}
const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
options,
onSelect
}) => {
const [value, setValue] = useState<string>();
const selectedOption = useMemo(() => options.find(option => option.label === value), [value])
const handleCreateNewTeamClicked = () => onSelect(undefined);
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
return (
<Stack gap="md">
<Button
leftSection={<PlusIcon weight="bold" />}
onClick={handleCreateNewTeamClicked}
variant="light"
fullWidth
>
Create New Team
</Button>
<Divider my="sm" label="or" />
<Stack gap="sm">
<Autocomplete
placeholder="Select one of your existing teams"
value={selectedOption?.label || ''}
onChange={setValue}
data={options.map(option => option.label)}
comboboxProps={{ withinPortal: false }}
/>
<Button
onClick={handleSelectExistingTeam}
disabled={!selectedOption}
fullWidth
>
Enroll Selected Team
</Button>
</Stack>
</Stack>
);
});
TeamSelectionView.displayName = 'TeamSelectionView';
export default TeamSelectionView;

View File

@@ -10,8 +10,8 @@ import toast from "@/lib/sonner";
import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "@/features/tournaments/queries";
import { DateTimePicker } from "@mantine/dates";
import { useCallback } from "react";
import { DateTimePicker } from "@/components/date-time-picker";
interface TournamentFormProps {
close: () => void;
@@ -166,6 +166,7 @@ const TournamentForm = ({
label="Start Date"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
@@ -186,6 +187,7 @@ const TournamentForm = ({
label="Enrollment Due"
withAsterisk
formatValue={(date) =>
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
@@ -206,17 +208,16 @@ const TournamentForm = ({
title="Select End Date"
label="End Date (Optional)"
formatValue={(date) =>
date
? new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
: "Not set"
!date ? 'Select a time' :
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
)}

View File

@@ -0,0 +1,146 @@
import { useEffect, useMemo, useState } from "react";
import { Tournament } from "../types";
import { useAuth } from "@/contexts/auth-context";
import {
Box,
Button,
Card,
Divider,
Group,
Stack,
Title,
Text,
} from "@mantine/core";
import Avatar from "@/components/avatar";
import Countdown from "@/components/countdown";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import {
TrophyIcon,
CalendarIcon,
MapPinIcon,
UsersIcon,
ListIcon,
} from "@phosphor-icons/react";
import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
}) => {
const { user, roles } = useAuth();
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
const userTeam = useMemo(
() =>
tournament.teams?.find((team) =>
team.players?.some((player) => player.id === user?.id)
),
[tournament.teams, user?.id]
);
const isUserEnrolled = !!userTeam;
const enrollmentDeadline = tournament.enroll_time
? new Date(tournament.enroll_time)
: new Date(tournament.start_time);
const tournamentStart = new Date(tournament.start_time);
const isEnrollmentOpen = enrollmentDeadline > new Date();
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<Group align="flex-start" gap="lg">
<Avatar
name={tournament.name}
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
radius="md"
size={80}
withBorder={false}
>
<TrophyIcon size={32} />
</Avatar>
<Stack gap="xs">
<Group gap="sm">
<Title order={2}>{tournament.name}</Title>
</Group>
{tournament.location && (
<Group gap="xs">
<MapPinIcon size={16} />
<Text size="sm" c="dimmed">
{tournament.location}
</Text>
</Group>
)}
<Group gap="xs">
<CalendarIcon size={16} />
<Text size="sm" c="dimmed">
{tournamentStart.toLocaleDateString()} at{" "}
{tournamentStart.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Group>
</Stack>
</Group>
</Group>
{tournament.desc && (
<Text size="sm">
{tournament.desc}
</Text>
)}
<Card withBorder radius="md" p="lg">
<Stack gap="md">
<Group 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 && (
<>
<EnrollTeam />
<Divider label="or" />
<EnrollFreeAgent />
</>
)}
</Stack>
</Card>
<Box>
<Divider />
{isAdmin && (
<ListLink
label={`Manage ${tournament.name}`}
to={`/admin/tournaments/${tournament.id}`}
Icon={UsersIcon}
/>
)}
<ListButton label="View Rules" Icon={ListIcon} onClick={() => {}} />
<ListButton
label={`View Teams (${tournament.teams?.length || 0})`}
Icon={UsersIcon}
onClick={() => {}}
/>
</Box>
</Stack>
);
};
export default UpcomingTournament;

View File

@@ -1,9 +1,10 @@
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { getCurrentTournament, 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
};
@@ -16,6 +17,10 @@ export const tournamentQueries = {
queryKey: tournamentKeys.details(id),
queryFn: () => getTournament({ data: id })
}),
current: () => ({
queryKey: tournamentKeys.current,
queryFn: getCurrentTournament
}),
unenrolled: (id: string) => ({
queryKey: tournamentKeys.unenrolled(id),
queryFn: () => getUnenrolledTeams({ data: id })
@@ -28,5 +33,8 @@ export const useTournaments = () =>
export const useTournament = (id: string) =>
useServerSuspenseQuery(tournamentQueries.details(id));
export const useCurrentTournament = () =>
useServerSuspenseQuery(tournamentQueries.current());
export const useUnenrolledTeams = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));

View File

@@ -36,6 +36,12 @@ export const getTournament = createServerFn()
toServerResult(() => pbAdmin.getTournament(tournamentId))
);
export const getCurrentTournament = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(() => pbAdmin.getMostRecentTournament())
);
export const enrollTeam = createServerFn()
.validator(z.object({
tournamentId: z.string(),