significant refactor

This commit is contained in:
2025-08-30 01:42:23 -05:00
parent 7136f646a3
commit 052f53444e
106 changed files with 1994 additions and 1701 deletions

View File

@@ -1,8 +1,14 @@
import { Autocomplete, Stack, ActionIcon, Text, Group, Loader } from "@mantine/core";
import {
Autocomplete,
Stack,
ActionIcon,
Text,
Group,
Loader,
} from "@mantine/core";
import { TrashIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useState, useCallback, useMemo, memo } from "react";
import { tournamentQueries } from "../queries";
import { useTournament, useUnenrolledTeams } from "../queries";
import useEnrollTeam from "../hooks/use-enroll-team";
import useUnenrollTeam from "../hooks/use-unenroll-team";
import Avatar from "@/components/avatar";
@@ -12,13 +18,17 @@ interface EditEnrolledTeamsProps {
tournamentId: string;
}
const TeamItem = memo(({ team, onUnenroll, disabled }: {
interface TeamItemProps {
team: Team;
onUnenroll: (teamId: string) => void;
disabled: boolean;
}) => {
const playerNames = useMemo(() =>
team.players?.map(p => `${p.first_name} ${p.last_name}`).join(", ") || "",
}
const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
const playerNames = useMemo(
() =>
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
"",
[team.players]
);
@@ -26,9 +36,13 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: {
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<Avatar size={32} name={team.name} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>{team.name}</Text>
<Text fw={500} truncate>
{team.name}
</Text>
{playerNames && (
<Text size="xs" c="dimmed" truncate>{playerNames}</Text>
<Text size="xs" c="dimmed" truncate>
{playerNames}
</Text>
)}
</Stack>
<ActionIcon
@@ -47,30 +61,43 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: {
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const [search, setSearch] = useState("");
const { data: tournament, isLoading: tournamentLoading } =
useQuery(tournamentQueries.details(tournamentId));
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
useQuery(tournamentQueries.unenrolled(tournamentId));
const { data: tournament, isLoading: tournamentLoading } =
useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
useUnenrolledTeams(tournamentId);
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
const autocompleteData = useMemo(() =>
unenrolledTeams.map((team: Team) => ({ value: team.id, label: team.name })),
const autocompleteData = useMemo(
() =>
unenrolledTeams.map((team: Team) => ({
value: team.id,
label: team.name,
})),
[unenrolledTeams]
);
const handleEnrollTeam = useCallback((teamId: string) => {
enrollTeam({ tournamentId, teamId }, {
onSuccess: () => {
setSearch("");
}
});
}, [enrollTeam, tournamentId, setSearch]);
const handleEnrollTeam = useCallback(
(teamId: string) => {
enrollTeam(
{ tournamentId, teamId },
{
onSuccess: () => {
setSearch("");
},
}
);
},
[enrollTeam, tournamentId, setSearch]
);
const handleUnenrollTeam = useCallback((teamId: string) => {
unenrollTeam({ tournamentId, teamId });
}, [unenrollTeam, tournamentId]);
const handleUnenrollTeam = useCallback(
(teamId: string) => {
unenrollTeam({ tournamentId, teamId });
},
[unenrollTeam, tournamentId]
);
const isLoading = tournamentLoading || unenrolledLoading;
const enrolledTeams = tournament?.teams || [];
@@ -79,7 +106,9 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
return (
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">Add Team</Text>
<Text fw={600} size="sm">
Add Team
</Text>
<Autocomplete
placeholder="Search for teams to enroll..."
data={autocompleteData}
@@ -95,18 +124,26 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">Enrolled Teams</Text>
<Text size="xs" c="dimmed">{enrolledTeams.length} teams</Text>
<Text fw={600} size="sm">
Enrolled Teams
</Text>
<Text size="xs" c="dimmed">
{enrolledTeams.length} teams
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : !hasEnrolledTeams ? (
<Text size="sm" c="dimmed" ta="center" py="lg">No teams enrolled yet</Text>
<Text size="sm" c="dimmed" ta="center" py="lg">
No teams enrolled yet
</Text>
) : (
<Stack gap="xs" w="100%">
{enrolledTeams.map((team: Team) => (
<TeamItem
<TeamItem
key={team.id}
team={team}
onUnenroll={handleUnenrollTeam}

View File

@@ -1,10 +1,13 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { tournamentQueries } from "../queries";
import { useTournament } from "../queries";
import { List } from "@mantine/core";
import ListButton from "@/components/list-button";
import Sheet from "@/components/sheet/sheet";
import TournamentForm from "./tournament-form";
import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from "@phosphor-icons/react";
import {
HardDrivesIcon,
PencilLineIcon,
UsersThreeIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
@@ -13,26 +16,52 @@ interface ManageTournamentProps {
}
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
const { data: tournament } = useSuspenseQuery(
tournamentQueries.details(tournamentId)
);
const { data: tournament } = useTournament(tournamentId);
if (!tournament) throw new Error("Tournament not found.");
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
const { isOpen: editRulesOpened, open: openEditRules, close: closeEditRules } = useSheet();
const { isOpen: editTeamsOpened, open: openEditTeams, close: closeEditTeams } = useSheet();
const {
isOpen: editTournamentOpened,
open: openEditTournament,
close: closeEditTournament,
} = useSheet();
const {
isOpen: editRulesOpened,
open: openEditRules,
close: closeEditRules,
} = useSheet();
const {
isOpen: editTeamsOpened,
open: openEditTeams,
close: closeEditTeams,
} = useSheet();
return (
<>
<List>
<ListButton label="Edit Tournament" Icon={HardDrivesIcon} onClick={openEditTournament} />
<ListButton label="Edit Rules" Icon={PencilLineIcon} onClick={openEditRules} />
<ListButton label="Edit Enrolled Teams" Icon={UsersThreeIcon} onClick={openEditTeams} />
<ListButton
label="Edit Tournament"
Icon={HardDrivesIcon}
onClick={openEditTournament}
/>
<ListButton
label="Edit Rules"
Icon={PencilLineIcon}
onClick={openEditRules}
/>
<ListButton
label="Edit Enrolled Teams"
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
</List>
<Sheet title="Edit Tournament" opened={editTournamentOpened} onChange={closeEditTournament}>
<TournamentForm
tournamentId={tournament.id}
<Sheet
title="Edit Tournament"
opened={editTournamentOpened}
onChange={closeEditTournament}
>
<TournamentForm
tournamentId={tournament.id}
initialValues={{
name: tournament.name,
location: tournament.location,
@@ -40,20 +69,28 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
start_time: tournament.start_time,
enroll_time: tournament.enroll_time,
end_time: tournament.end_time,
}}
close={closeEditTournament}
}}
close={closeEditTournament}
/>
</Sheet>
<Sheet title="Edit Rules" opened={editRulesOpened} onChange={closeEditRules}>
<Sheet
title="Edit Rules"
opened={editRulesOpened}
onChange={closeEditRules}
>
<p>Test</p>
</Sheet>
<Sheet title="Edit Enrolled Teams" opened={editTeamsOpened} onChange={closeEditTeams}>
<Sheet
title="Edit Enrolled Teams"
opened={editTeamsOpened}
onChange={closeEditTeams}
>
<EditEnrolledTeams tournamentId={tournamentId} />
</Sheet>
</>
)
);
};
export default ManageTournament;

View File

@@ -1,15 +1,17 @@
import { Box, Divider, Text } from "@mantine/core";
import { Box, Text } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import { Tournament } from "../../types";
import { PreviewBracket } from "@/features/bracket/components/preview";
import { useTournament } from "../../queries";
interface ProfileProps {
tournament: Tournament;
id: string;
}
const Profile = ({ tournament }: ProfileProps) => {
const Profile = ({ id }: ProfileProps) => {
const { data: tournament } = useTournament(id);
if (!tournament) return null;
const tabs = [
{
label: "Overview",

View File

@@ -1,50 +1,73 @@
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core"
import { Tournament } from "@/features/tournaments/types"
import { useMemo } from "react"
import { CaretRightIcon, TrophyIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router"
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { useMemo } from "react";
import { CaretRightIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
interface TournamentCardProps {
tournament: Tournament
tournament: Tournament;
}
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate({ from: '/tournaments/$tournamentId' })
const navigate = useNavigate();
const displayDate = useMemo(() => {
if (!tournament.start_time) return null
const date = new Date(tournament.start_time)
if (isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}, [tournament.start_time])
if (!tournament.start_time) return null;
const date = new Date(tournament.start_time);
if (isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}, [tournament.start_time]);
return (
<Card shadow="sm" padding="lg" radius="md" withBorder style={{ cursor: 'pointer' }} onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}>
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{ cursor: "pointer" }}
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
>
<Stack>
<Flex align='center' gap='md'>
<Flex align="center" gap="md">
<Image
src={tournament.logo ? `/api/files/tournaments/${tournament.id}/${tournament.logo}` : undefined}
maw={100}
mah={100}
fit='contain'
fit="contain"
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
alt={tournament.name}
fallbackSrc={"TODO"}
/>
<Stack ta='center' mx='auto' gap='0'>
<Text size='lg' fw={800}>{tournament.name} <CaretRightIcon size={12} weight='bold' /></Text>
{displayDate && <Text c='dimmed' size='xs' fw={600}>{displayDate}</Text>}
<Stack gap={4} mt={4}>
{ /* TODO: Add medalists when data is available */}
<Badge variant='dot' color='gold'>Longer Team Name Goes Here</Badge>
<Badge variant='dot' color='silver'>Some Team</Badge>
<Badge variant='dot' color='orange'>Medium Team Name</Badge>
</Stack>
/>
<Stack ta="center" mx="auto" gap="0">
<Text size="lg" fw={800}>
{tournament.name} <CaretRightIcon size={12} weight="bold" />
</Text>
{displayDate && (
<Text c="dimmed" size="xs" fw={600}>
{displayDate}
</Text>
)}
<Stack gap={4} mt={4}>
{/* TODO: Add medalists when data is available */}
<Badge variant="dot" color="gold">
Longer Team Name Goes Here
</Badge>
<Badge variant="dot" color="silver">
Some Team
</Badge>
<Badge variant="dot" color="orange">
Medium Team Name
</Badge>
</Stack>
</Stack>
</Flex>
</Stack>
</Card>
)
}
);
};

View File

@@ -2,106 +2,124 @@ 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 { TournamentFormInput } from "@/features/tournaments/types";
import { TournamentInput } from "@/features/tournaments/types";
import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-tournament";
import useUpdateTournament from "../hooks/use-update-tournament";
import toast from '@/lib/sonner';
import toast from "@/lib/sonner";
import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries";
import { tournamentKeys } from "@/features/tournaments/queries";
import { DateTimePicker } from "@mantine/dates";
import { useCallback } from "react";
interface TournamentFormProps {
close: () => void;
initialValues?: Partial<TournamentFormInput>;
initialValues?: Partial<TournamentInput>;
tournamentId?: string;
}
const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormProps) => {
const TournamentForm = ({
close,
initialValues,
tournamentId,
}: TournamentFormProps) => {
const isEditMode = !!tournamentId;
const config: UseFormInput<TournamentFormInput> = {
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 || '',
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',
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'),
}
}
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 { mutate: createTournament, isPending: createPending } =
useCreateTournament();
const { mutate: updateTournament, isPending: updatePending } =
useUpdateTournament(tournamentId || "");
const isPending = createPending || updatePending;
const handleSubmit = useCallback(async (values: TournamentFormInput) => {
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 handleSubmit = useCallback(
async (values: TournamentInput) => {
const { logo, ...tournamentData } = values;
const response = await fetch('/api/tournaments/upload-logo', {
method: 'POST',
body: formData,
});
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";
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload logo');
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);
}
const result = await response.json();
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(result.tournament!.id).queryKey });
queryClient.setQueryData(
tournamentQueries.details(result.tournament!.id).queryKey,
result.tournament
);
} else {
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]);
close();
},
onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`);
logger.error(
`Tournament ${isEditMode ? "update" : "create"} error`,
error
);
},
});
},
[isEditMode, createTournament, updateTournament, queryClient]
);
return (
<SlidePanel
@@ -115,83 +133,91 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
<TextInput
label="Name"
withAsterisk
key={form.key('name')}
{...form.getInputProps('name')}
key={form.key("name")}
{...form.getInputProps("name")}
/>
<TextInput
label="Location"
withAsterisk
key={form.key('location')}
{...form.getInputProps('location')}
key={form.key("location")}
{...form.getInputProps("location")}
/>
<Textarea
label="Description"
key={form.key('desc')}
{...form.getInputProps('desc')}
key={form.key("desc")}
{...form.getInputProps("desc")}
minRows={3}
/>
<FileInput
key={form.key('logo')}
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')}
{...form.getInputProps("logo")}
/>
<SlidePanelField
key={form.key('start_time')}
{...form.getInputProps('start_time')}
key={form.key("start_time")}
{...form.getInputProps("start_time")}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
formatValue={(date) =>
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')}
key={form.key("enroll_time")}
{...form.getInputProps("enroll_time")}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
formatValue={(date) =>
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')}
key={form.key("end_time")}
{...form.getInputProps("end_time")}
Component={DateTimePicker}
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'}
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"
}
/>
)}
</Stack>
@@ -199,4 +225,4 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
);
};
export default TournamentForm;
export default TournamentForm;

View File

@@ -1,35 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { createTournament } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { TournamentInput } from "@/features/tournaments/types";
import { logger } from "../";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useCreateTournament = () => {
const navigate = useNavigate();
return useMutation({
return useServerMutation({
mutationFn: (data: TournamentInput) => createTournament({ data }),
onMutate: (data) => {
logger.info('Creating tournament', data);
},
onSuccess: (data) => {
if (!data) {
toast.error('There was an issue creating your tournament. Please try again later.');
logger.error('Error creating tournament', data);
} else {
logger.info('Tournament created successfully', data);
navigate({ to: '/tournaments' });
}
},
onError: (error: any) => {
logger.error('Error creating tournament', error);
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to create a tournament. Please try again later.');
}
onSuccess: () => {
navigate({ to: '/tournaments' });
},
successMessage: 'Tournament created successfully!',
});
};

View File

@@ -1,31 +1,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { enrollTeam } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useEnrollTeam = () => {
const queryClient = useQueryClient();
return useMutation({
return useServerMutation({
mutationFn: (data: { tournamentId: string, teamId: string }) => {
return enrollTeam({ data });
},
onSuccess: (data, { tournamentId }) => {
if (!data) {
toast.error('There was an issue enrolling. Please try again later.');
} else {
// Invalidate both tournament details and unenrolled teams queries
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
toast.success('Team enrolled successfully!');
}
},
onError: (error: any) => {
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to enroll the team. Please try again later.');
}
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
},
successMessage: 'Team enrolled successfully!',
});
};

View File

@@ -1,31 +1,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { unenrollTeam } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useUnenrollTeam = () => {
const queryClient = useQueryClient();
return useMutation({
return useServerMutation({
mutationFn: (data: { tournamentId: string, teamId: string }) => {
return unenrollTeam({ data });
},
onSuccess: (data, { tournamentId }) => {
if (!data) {
toast.error('There was an issue unenrolling. Please try again later.');
} else {
// Invalidate both tournament details and unenrolled teams queries
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
toast.success('Team unenrolled successfully.');
}
},
onError: (error: any) => {
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to unenroll the team. Please try again later.');
}
onSuccess: (_, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
},
successMessage: 'Team unenrolled successfully.',
});
};

View File

@@ -1,10 +1,10 @@
import { useMutation } from "@tanstack/react-query";
import { updateTournament } from "@/features/tournaments/server";
import { TournamentFormInput } from "@/features/tournaments/types";
import { TournamentInput } from "@/features/tournaments/types";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useUpdateTournament = (tournamentId: string) => {
return useMutation({
mutationFn: (data: Partial<TournamentFormInput>) =>
return useServerMutation({
mutationFn: (data: Partial<TournamentInput>) =>
updateTournament({ data: { id: tournamentId, updates: data } }),
});
};

View File

@@ -1,23 +1,32 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
const tournamentKeys = {
export const tournamentKeys = {
list: ['tournaments', 'list'] as const,
details: (id: string) => ['tournaments', 'details', id] as const,
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
};
export const tournamentQueries = {
list: () => queryOptions({
list: () => ({
queryKey: tournamentKeys.list,
queryFn: listTournaments
}),
details: (id: string) => queryOptions({
details: (id: string) => ({
queryKey: tournamentKeys.details(id),
queryFn: () => getTournament({ data: id })
}),
unenrolled: (id: string) => queryOptions({
unenrolled: (id: string) => ({
queryKey: tournamentKeys.unenrolled(id),
queryFn: () => getUnenrolledTeams({ data: id })
})
};
export const useTournaments = () =>
useServerSuspenseQuery(tournamentQueries.list());
export const useTournament = (id: string) =>
useServerSuspenseQuery(tournamentQueries.details(id));
export const useUnenrolledTeams = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));

View File

@@ -4,34 +4,20 @@ import { pbAdmin } from "@/lib/pocketbase/client";
import { tournamentInputSchema } from "@/features/tournaments/types";
import { logger } from ".";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
export const listTournaments = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
const result = await pbAdmin.listTournaments();
return result;
} catch (error) {
logger.error('Error fetching tournaments', error);
return [];
}
});
.handler(async () =>
toServerResult(pbAdmin.listTournaments)
);
export const createTournament = createServerFn()
.validator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => {
try {
logger.info('Creating tournament', data);
const tournament = await pbAdmin.createTournament(data);
return tournament;
} catch (error) {
logger.error('Error creating tournament', error);
return null;
}
});
.handler(async ({ data }) =>
toServerResult(() => pbAdmin.createTournament(data))
);
export const updateTournament = createServerFn()
.validator(z.object({
@@ -39,26 +25,16 @@ export const updateTournament = createServerFn()
updates: tournamentInputSchema.partial()
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => {
try {
logger.info('Updating tournament', data);
const tournament = await pbAdmin.updateTournament(data.id, data.updates);
return tournament;
} catch (error) {
logger.error('Error updating tournament', error);
return null;
}
});
.handler(async ({ data }) =>
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
);
export const getTournament = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => {
logger.info('Getting tournament', tournamentId);
const tournament = await pbAdmin.getTournament(tournamentId);
return tournament;
});
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getTournament(tournamentId))
);
export const enrollTeam = createServerFn()
.validator(z.object({
@@ -66,8 +42,8 @@ export const enrollTeam = createServerFn()
teamId: z.string()
}))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { tournamentId, teamId }, context }) => {
try {
.handler(async ({ data: { tournamentId, teamId }, context }) =>
toServerResult(async () => {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
@@ -83,11 +59,8 @@ export const enrollTeam = createServerFn()
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
return tournament;
} catch (error) {
logger.error('Error enrolling team', error);
throw error;
}
});
})
);
export const unenrollTeam = createServerFn()
.validator(z.object({
@@ -95,22 +68,13 @@ export const unenrollTeam = createServerFn()
teamId: z.string()
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId, teamId }, context }) => {
try {
logger.info('Enrolling team in tournament', { tournamentId, teamId, context });
const tournament = await pbAdmin.unenrollTeam(tournamentId, teamId);
return tournament;
} catch (error) {
logger.error('Error enrolling team', error);
throw error;
}
});
.handler(async ({ data: { tournamentId, teamId }, context }) =>
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
);
export const getUnenrolledTeams = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) => {
logger.info('Getting unenrolled teams', tournamentId);
const teams = await pbAdmin.getUnenrolledTeams(tournamentId);
return teams;
});
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
);

View File

@@ -16,19 +16,6 @@ export interface Tournament {
teams?: Team[];
}
// Schema for the form (client-side)
export const tournamentFormSchema = z.object({
name: z.string(),
location: z.string().optional(),
desc: z.string().optional(),
rules: z.string().optional(),
logo: z.file().optional(),
enroll_time: z.string(),
start_time: z.string(),
end_time: z.string().optional(),
});
// Schema for the server input (with base64 logo)
export const tournamentInputSchema = z.object({
name: z.string(),
location: z.string().optional(),
@@ -40,6 +27,5 @@ export const tournamentInputSchema = z.object({
end_time: z.string().optional(),
});
export type TournamentFormInput = z.infer<typeof tournamentFormSchema>;
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
export type TournamentUpdateInput = Partial<TournamentInput>;