work on team enrollment
This commit is contained in:
@@ -2,11 +2,7 @@ import { Box } from "@mantine/core"
|
||||
import { ArrowLeftIcon } from "@phosphor-icons/react"
|
||||
import { useRouter } from "@tanstack/react-router"
|
||||
|
||||
interface BackButtonProps {
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
const BackButton = ({ offsetY }: BackButtonProps) => {
|
||||
const BackButton = () => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { Title, AppShell, Flex } from "@mantine/core";
|
||||
import { HeaderConfig } from "../types/header-config";
|
||||
import BackButton from "./back-button";
|
||||
import { useMemo } from "react";
|
||||
import SettingsButton from "./settings-button";
|
||||
interface HeaderProps extends HeaderConfig {
|
||||
scrollPosition: { x: number, y: number };
|
||||
}
|
||||
|
||||
const Header = ({ withBackButton, settingsLink, collapsed, title, scrollPosition }: HeaderProps) => {
|
||||
const offsetY = useMemo(() => {
|
||||
return collapsed ? scrollPosition.y : 0;
|
||||
}, [collapsed, scrollPosition.y]);
|
||||
interface HeaderProps extends HeaderConfig {}
|
||||
|
||||
const Header = ({ collapsed, title }: HeaderProps) => {
|
||||
|
||||
return (
|
||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
||||
|
||||
@@ -33,7 +33,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
mah='100dvh'
|
||||
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
||||
>
|
||||
<Header scrollPosition={scrollPosition} {...header} />
|
||||
<Header {...header} />
|
||||
<AppShell.Main
|
||||
pos='relative'
|
||||
h='100%'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NavLink } from "./nav-link";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useLinks } from "../hooks/use-links";
|
||||
import { memo } from "react";
|
||||
|
||||
const Navbar = () => {
|
||||
const { user, roles } = useAuth()
|
||||
@@ -35,4 +36,4 @@ const Navbar = () => {
|
||||
</AppShell.Navbar>
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
export default memo(Navbar);
|
||||
|
||||
@@ -14,7 +14,6 @@ interface PullableProps extends PropsWithChildren {
|
||||
|
||||
/**
|
||||
* Pullable is a component that allows the user to pull down to refresh the page
|
||||
* TODO: Need to make the router config nicer
|
||||
*/
|
||||
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
|
||||
const height = useAppShellHeight();
|
||||
@@ -110,10 +109,6 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
||||
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
|
||||
>
|
||||
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
|
||||
{ /* TODO: Remove this debug button */}
|
||||
<ActionIcon display={!!refresh.length ? 'unset' : 'none' } style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
|
||||
<ArrowClockwiseIcon />
|
||||
</ActionIcon>
|
||||
{children}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { GearIcon } from "@phosphor-icons/react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { memo } from "react";
|
||||
|
||||
interface SettingButtonProps {
|
||||
offsetY: number;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const SettingsButton = ({ offsetY, to }: SettingButtonProps) => {
|
||||
const SettingsButton = ({ to }: SettingButtonProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
@@ -23,4 +23,4 @@ const SettingsButton = ({ offsetY, to }: SettingButtonProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsButton;
|
||||
export default memo(SettingsButton, (prev, next) => prev.to !== next.to);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Indicator,
|
||||
Box,
|
||||
Badge,
|
||||
Skeleton,
|
||||
} from "@mantine/core";
|
||||
import { TrophyIcon, CrownIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
@@ -54,7 +55,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
</Text>
|
||||
<Text c="dimmed">-</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Round {match.round}
|
||||
Round {match.round + 1}
|
||||
{match.is_losers_bracket && " (Losers)"}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -81,7 +82,7 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
size="md"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
@@ -89,42 +90,17 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
{match.home?.name!}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
>
|
||||
{match.home_cups}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" align="center">
|
||||
<Text
|
||||
size="md"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
>
|
||||
{match.home_cups}
|
||||
</Text>
|
||||
<Text size="md" c="dimmed" fw={500}>
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
size="md"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
>
|
||||
{match.away_cups}
|
||||
</Text>
|
||||
{match.ot_count > 0 && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
{match.ot_count}OT
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" style={{ flex: 1 }} justify="flex-end">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
ta="right"
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
{match.away?.name}
|
||||
</Text>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm" style={{ flex: 1 }}>
|
||||
<Box
|
||||
style={{ position: "relative", cursor: "pointer" }}
|
||||
onClick={handleAwayTeamClick}
|
||||
@@ -135,8 +111,8 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -10,
|
||||
right: -4,
|
||||
transform: "rotate(25deg)",
|
||||
left: -4,
|
||||
transform: "rotate(-25deg)",
|
||||
color: "gold",
|
||||
}}
|
||||
>
|
||||
@@ -144,7 +120,22 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="md"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
>
|
||||
{match.away?.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
>
|
||||
{match.away_cups}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Stack, Text, ThemeIcon, Box } from "@mantine/core";
|
||||
import { TrophyIcon } from "@phosphor-icons/react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Match } from "../types";
|
||||
import MatchCard from "./match-card";
|
||||
@@ -14,20 +13,11 @@ const MatchList = ({ matches }: MatchListProps) => {
|
||||
) || [];
|
||||
|
||||
if (!filteredMatches.length) {
|
||||
return (
|
||||
<Box ta="center" py="xl">
|
||||
<ThemeIcon size="xl" variant="light" radius="md" mb="md">
|
||||
<TrophyIcon size={32} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed" size="lg">
|
||||
No matches found
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Stack p="md" gap="sm">
|
||||
<AnimatePresence>
|
||||
{filteredMatches.map((match, index) => (
|
||||
<motion.div
|
||||
|
||||
@@ -13,7 +13,6 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
const Header = ({ player }: HeaderProps) => {
|
||||
|
||||
const sheet = useSheet();
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Box } from "@mantine/core";
|
||||
import Header from "./header";
|
||||
import { Player, PlayerStats } from "@/features/players/types";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import StatsOverview from "@/shared/components/stats-overview";
|
||||
import MatchList from "@/features/matches/components/match-list";
|
||||
import { BaseStats } from "@/shared/types/stats";
|
||||
|
||||
interface ProfileProps {
|
||||
id: string;
|
||||
@@ -17,26 +15,14 @@ const Profile = ({ id }: ProfileProps) => {
|
||||
const { data: matches } = usePlayerMatches(id);
|
||||
const { data: stats, isLoading: statsLoading } = usePlayerStats(id);
|
||||
|
||||
// Aggregate player stats from multiple tournaments into a single BaseStats object
|
||||
const aggregatedStats: BaseStats | null = stats && stats.length > 0 ? {
|
||||
id: `player_${id}_aggregate`,
|
||||
matches: stats.reduce((acc, stat) => acc + stat.matches, 0),
|
||||
wins: stats.reduce((acc, stat) => acc + stat.wins, 0),
|
||||
losses: stats.reduce((acc, stat) => acc + stat.losses, 0),
|
||||
total_cups_made: stats.reduce((acc, stat) => acc + stat.total_cups_made, 0),
|
||||
total_cups_against: stats.reduce((acc, stat) => acc + stat.total_cups_against, 0),
|
||||
margin_of_victory: stats.filter(s => s.margin_of_victory > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_victory / arr.length, 0),
|
||||
margin_of_loss: stats.filter(s => s.margin_of_loss > 0).reduce((acc, stat, _, arr) => acc + stat.margin_of_loss / arr.length, 0),
|
||||
} : null;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <StatsOverview statsData={aggregatedStats} isLoading={statsLoading} />,
|
||||
content: <StatsOverview statsData={stats} isLoading={statsLoading} />,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <Box p="md"><MatchList matches={matches || []} /></Box>,
|
||||
content: <MatchList matches={matches || []} />,
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
@@ -47,7 +33,7 @@ const Profile = ({ id }: ProfileProps) => {
|
||||
return (
|
||||
<>
|
||||
<Header player={player} />
|
||||
<Box m='md' mt="lg">
|
||||
<Box m='xs' mt="lg">
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches } from "./server";
|
||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers } from "./server";
|
||||
|
||||
export const playerKeys = {
|
||||
auth: ['auth'],
|
||||
list: ['players', 'list'],
|
||||
details: (id: string) => ['players', 'details', id],
|
||||
unassociated: ['players','unassociated'],
|
||||
unenrolled: (tournamentId: string) => ['players', 'unenrolled', tournamentId],
|
||||
stats: (id: string) => ['players', 'stats', id],
|
||||
allStats: ['players', 'stats', 'all'],
|
||||
matches: (id: string) => ['players', 'matches', id],
|
||||
@@ -28,6 +29,10 @@ export const playerQueries = {
|
||||
queryKey: playerKeys.unassociated,
|
||||
queryFn: async () => await getUnassociatedPlayers()
|
||||
}),
|
||||
unenrolled: (tournamentId: string) => ({
|
||||
queryKey: playerKeys.unenrolled(tournamentId),
|
||||
queryFn: async () => await getUnenrolledPlayers({ data: tournamentId })
|
||||
}),
|
||||
stats: (id: string) => ({
|
||||
queryKey: playerKeys.stats(id),
|
||||
queryFn: async () => await getPlayerStats({ data: id })
|
||||
@@ -81,4 +86,7 @@ export const useAllPlayerStats = () =>
|
||||
useServerSuspenseQuery(playerQueries.allStats());
|
||||
|
||||
export const usePlayerMatches = (id: string) =>
|
||||
useServerSuspenseQuery(playerQueries.matches(id));
|
||||
useServerSuspenseQuery(playerQueries.matches(id));
|
||||
|
||||
export const useUnenrolledPlayers = (tournamentId: string) =>
|
||||
useServerSuspenseQuery(playerQueries.unenrolled(tournamentId));
|
||||
@@ -125,7 +125,7 @@ export const getPlayerStats = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<PlayerStats[]>(async () => await pbAdmin.getPlayerStats(data))
|
||||
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
||||
);
|
||||
|
||||
export const getAllPlayerStats = createServerFn()
|
||||
@@ -139,4 +139,11 @@ export const getPlayerMatches = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||
);
|
||||
|
||||
export const getUnenrolledPlayers = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||
);
|
||||
80
src/features/teams/components/team-form/color-picker.tsx
Normal file
80
src/features/teams/components/team-form/color-picker.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
const presetColors = [
|
||||
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
|
||||
'#FF00FF', '#00FFFF', '#FFA500', '#800080',
|
||||
'#008000', '#000080', '#800000', '#808000'
|
||||
];
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Stack gap="md" p="md" w="100%">
|
||||
<Text fw={500}>{label}</Text>
|
||||
|
||||
<ColorPicker
|
||||
value={value || '#000000'}
|
||||
onChange={handleColorChange}
|
||||
format="hex"
|
||||
swatches={presetColors}
|
||||
withPicker={true}
|
||||
fullWidth
|
||||
size="md"
|
||||
swatchesPerRow={12}
|
||||
/>
|
||||
|
||||
<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}
|
||||
w="100%"
|
||||
/>
|
||||
|
||||
{isValidHex && (
|
||||
<ColorSwatch
|
||||
color={customHex}
|
||||
size={36}
|
||||
style={{ marginBottom: customHex && !isValidHex ? 24 : 0 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamColorPicker;
|
||||
@@ -1,81 +1,88 @@
|
||||
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
|
||||
import { Badge, FileInput, Group, Stack, Text, TextInput } 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 useCreateTeam from "../../hooks/use-create-team";
|
||||
import useUpdateTeam from "../../hooks/use-update-team";
|
||||
import toast from "@/lib/sonner";
|
||||
import { logger } from "..";
|
||||
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";
|
||||
import { TeamInput } from "../../types";
|
||||
import { teamKeys } from "../../queries";
|
||||
import SongPicker from "./song-picker";
|
||||
import TeamColorPicker from "./color-picker";
|
||||
import PlayersPicker from "./players-picker";
|
||||
|
||||
interface TournamentFormProps {
|
||||
interface TeamFormProps {
|
||||
close: () => void;
|
||||
initialValues?: Partial<TournamentInput>;
|
||||
initialValues?: Partial<TeamInput>;
|
||||
teamId?: string;
|
||||
tournamentId?: string;
|
||||
}
|
||||
|
||||
const TournamentForm = ({
|
||||
const TeamForm = ({
|
||||
close,
|
||||
initialValues,
|
||||
teamId,
|
||||
tournamentId,
|
||||
}: TournamentFormProps) => {
|
||||
const isEditMode = !!tournamentId;
|
||||
}: TeamFormProps) => {
|
||||
const isEditMode = !!teamId;
|
||||
|
||||
const config: UseFormInput<TournamentInput> = {
|
||||
const config: UseFormInput<TeamInput> = {
|
||||
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 || "",
|
||||
primary_color: initialValues?.primary_color,
|
||||
accent_color: initialValues?.accent_color,
|
||||
song_id: initialValues?.song_id,
|
||||
song_name: initialValues?.song_name,
|
||||
song_artist: initialValues?.song_artist,
|
||||
song_album: initialValues?.song_album,
|
||||
song_year: initialValues?.song_year,
|
||||
song_start: initialValues?.song_start,
|
||||
song_end: initialValues?.song_end,
|
||||
song_image_url: initialValues?.song_image_url,
|
||||
logo: undefined,
|
||||
players: initialValues?.players || []
|
||||
},
|
||||
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"),
|
||||
players: (value: string[]) => value.length > 1 && value[1] !== '' ? undefined : "Players are 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 } = useCreateTeam();
|
||||
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(teamId!);
|
||||
|
||||
const isPending = createPending || updatePending;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (values: TournamentInput) => {
|
||||
const { logo, ...tournamentData } = values;
|
||||
async (values: TeamInput) => {
|
||||
const { logo, ...teamData } = values;
|
||||
|
||||
const mutation = isEditMode ? updateTournament : createTournament;
|
||||
const successMessage = isEditMode
|
||||
? "Tournament updated successfully!"
|
||||
: "Tournament created successfully!";
|
||||
? "Team updated successfully!"
|
||||
: "Team created successfully!";
|
||||
const errorMessage = isEditMode
|
||||
? "Failed to update tournament"
|
||||
: "Failed to create tournament";
|
||||
? "Failed to update team"
|
||||
: "Failed to create team";
|
||||
|
||||
mutation(tournamentData, {
|
||||
onSuccess: async (tournament) => {
|
||||
if (logo && tournament) {
|
||||
mutation(teamData, {
|
||||
onSuccess: async (team) => {
|
||||
if (logo && team) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("tournamentId", tournament.id);
|
||||
formData.append("teamId", team.id);
|
||||
formData.append("logo", logo);
|
||||
|
||||
const response = await fetch("/api/tournaments/upload-logo", {
|
||||
const response = await fetch("/api/teams/upload-logo", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
@@ -87,22 +94,22 @@ const TournamentForm = ({
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
|
||||
queryClient.invalidateQueries({ queryKey: teamKeys.list });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(result.tournament!.id),
|
||||
queryKey: teamKeys.details(result.team!.id),
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
tournamentKeys.details(result.tournament!.id),
|
||||
result.tournament
|
||||
tournamentKeys.details(result.team!.id),
|
||||
result.team
|
||||
);
|
||||
|
||||
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}`;
|
||||
? `Team updated but logo upload failed: ${error.message}`
|
||||
: `Team created but logo upload failed: ${error.message}`;
|
||||
toast.error(logoErrorMessage);
|
||||
logger.error("Tournament logo upload error", error);
|
||||
logger.error("Team logo upload error", error);
|
||||
}
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
@@ -112,7 +119,7 @@ const TournamentForm = ({
|
||||
onError: (error: any) => {
|
||||
toast.error(`${errorMessage}: ${error.message}`);
|
||||
logger.error(
|
||||
`Tournament ${isEditMode ? "update" : "create"} error`,
|
||||
`Team ${isEditMode ? "update" : "create"} error`,
|
||||
error
|
||||
);
|
||||
},
|
||||
@@ -123,9 +130,9 @@ const TournamentForm = ({
|
||||
|
||||
return (
|
||||
<SlidePanel
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
onSubmit={form.onSubmit((values) => console.log(values))}
|
||||
onCancel={close}
|
||||
submitText={isEditMode ? "Update Tournament" : "Create Tournament"}
|
||||
submitText={isEditMode ? "Update Team" : "Create Team"}
|
||||
cancelText="Cancel"
|
||||
loading={isPending}
|
||||
>
|
||||
@@ -136,19 +143,6 @@ const TournamentForm = ({
|
||||
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")}
|
||||
@@ -158,6 +152,50 @@ const TournamentForm = ({
|
||||
{...form.getInputProps("logo")}
|
||||
/>
|
||||
|
||||
{
|
||||
tournamentId && (
|
||||
<PlayersPicker
|
||||
tournamentId={tournamentId}
|
||||
key={form.key("players")}
|
||||
{...form.getInputProps("players")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<SlidePanelField
|
||||
key={form.key("primary_color")}
|
||||
{...form.getInputProps("primary_color")}
|
||||
Component={TeamColorPicker}
|
||||
title="Select Primary Color"
|
||||
placeholder="Select Primary Color"
|
||||
label="Primary Color"
|
||||
formatValue={(value) => (
|
||||
<Group>
|
||||
<Badge variant="filled" radius="sm" color={value} />
|
||||
{value}
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SlidePanelField
|
||||
key={form.key("accent_color")}
|
||||
{...form.getInputProps("accent_color")}
|
||||
Component={TeamColorPicker}
|
||||
title="Select Accent Color"
|
||||
placeholder="Select Accent Color"
|
||||
label="Accent Color"
|
||||
formatValue={(value) => (
|
||||
<Group>
|
||||
<Badge variant="filled" radius="sm" color={value} />
|
||||
{value}
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SongPicker form={form} />
|
||||
|
||||
|
||||
{/*
|
||||
<SlidePanelField
|
||||
key={form.key("start_time")}
|
||||
{...form.getInputProps("start_time")}
|
||||
@@ -221,9 +259,10 @@ const TournamentForm = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
*/}
|
||||
</Stack>
|
||||
</SlidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default TournamentForm;
|
||||
export default TeamForm;
|
||||
62
src/features/teams/components/team-form/players-picker.tsx
Normal file
62
src/features/teams/components/team-form/players-picker.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useUnenrolledPlayers } from "@/features/players/queries";
|
||||
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface PlayersPickerProps {
|
||||
tournamentId: string;
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
|
||||
const { roles, user } = useAuth();
|
||||
const isAdmin = roles.includes("Admin");
|
||||
|
||||
const { data } = useUnenrolledPlayers(tournamentId);
|
||||
|
||||
const options = useMemo(() => data.filter(p => p.id != user?.id).map(p => (p.first_name + " " + p.last_name)), [data])
|
||||
|
||||
const findPlayerId = useCallback((name?: string) => {
|
||||
const player = data.find(player => player?.first_name + " " + player?.last_name === name)
|
||||
return player?.id || ""
|
||||
}, [data])
|
||||
|
||||
const findPlayerName = useCallback((id?: string) => {
|
||||
const player = data.find(player => player.id === id)
|
||||
return player?.first_name + " " + player?.last_name
|
||||
}, [data])
|
||||
|
||||
const [playerOne, setPlayerOne] = useState(value.length ? findPlayerName(value[0]) : findPlayerName(user!.id));
|
||||
const [playerTwo, setPlayerTwo] = useState(value.length > 1 ? findPlayerName(value[1]) : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
onChange([findPlayerId(playerOne), findPlayerId(playerTwo)])
|
||||
}, [playerOne, playerTwo])
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Autocomplete
|
||||
label="Player 1"
|
||||
data={options}
|
||||
disabled={!isAdmin || disabled}
|
||||
value={playerOne}
|
||||
onChange={setPlayerOne}
|
||||
/>
|
||||
<Autocomplete
|
||||
label="Player 2"
|
||||
data={options}
|
||||
disabled={disabled && !isAdmin}
|
||||
value={playerTwo}
|
||||
onChange={setPlayerTwo}
|
||||
withAsterisk
|
||||
error={error}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayersPicker;
|
||||
56
src/features/teams/components/team-form/song-picker.tsx
Normal file
56
src/features/teams/components/team-form/song-picker.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { UseFormReturnType } from "@mantine/form";
|
||||
import { TeamInput } from "../../types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Song {
|
||||
song_id: string;
|
||||
song_name: string;
|
||||
song_artist: string;
|
||||
song_album: string;
|
||||
song_year?: number;
|
||||
song_start?: number;
|
||||
song_end?: number;
|
||||
song_image_url: string;
|
||||
}
|
||||
|
||||
interface SongPickerProps {
|
||||
form: UseFormReturnType<TeamInput>
|
||||
}
|
||||
|
||||
const SongPicker = ({ form }: SongPickerProps) => {
|
||||
const [song, setSong] = useState<Song>();
|
||||
|
||||
useEffect(() => {
|
||||
const values = form.getValues();
|
||||
|
||||
setSong({
|
||||
song_id: values.song_id || "",
|
||||
song_name: values.song_name || "",
|
||||
song_artist: values.song_artist || "",
|
||||
song_album: values.song_album || "",
|
||||
song_year: values.song_year,
|
||||
song_start: values.song_start,
|
||||
song_end: values.song_end,
|
||||
song_image_url: values.song_image_url || "",
|
||||
})
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SlidePanelField
|
||||
key={"song-picker"}
|
||||
value={""}
|
||||
onChange={console.log}
|
||||
Component={() => (
|
||||
<Stack>
|
||||
<Text>Song picker</Text>
|
||||
</Stack>
|
||||
)}
|
||||
title={"Select Song"}
|
||||
label={"Song"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SongPicker;
|
||||
@@ -1,77 +0,0 @@
|
||||
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;
|
||||
@@ -3,7 +3,8 @@ import Header from "./header";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import TournamentList from "@/features/tournaments/components/tournament-list";
|
||||
import StatsOverview from "@/shared/components/stats-overview";
|
||||
import { useTeam, useTeamStats } from "../../queries";
|
||||
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
|
||||
import MatchList from "@/features/matches/components/match-list";
|
||||
|
||||
interface ProfileProps {
|
||||
id: string;
|
||||
@@ -11,6 +12,7 @@ interface ProfileProps {
|
||||
|
||||
const TeamProfile = ({ id }: ProfileProps) => {
|
||||
const { data: team } = useTeam(id);
|
||||
const { data: matches } = useTeamMatches(id);
|
||||
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
|
||||
if (!team) return <Text p="md">Team not found</Text>;
|
||||
|
||||
@@ -21,7 +23,7 @@ const TeamProfile = ({ id }: ProfileProps) => {
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <Text p="md">Matches feed will go here</Text>,
|
||||
content: <MatchList matches={matches || []} />,
|
||||
},
|
||||
{
|
||||
label: "Tournaments",
|
||||
@@ -35,7 +37,7 @@ const TeamProfile = ({ id }: ProfileProps) => {
|
||||
return (
|
||||
<>
|
||||
<Header name={team.name} logo={team.logo} />
|
||||
<Box m="sm" mt="lg">
|
||||
<Box m="xs" mt="lg">
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useServerSuspenseQuery, useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { getTeam, getTeamStats } from "./server";
|
||||
import { getTeam, getTeamMatches, getTeamStats } from "./server";
|
||||
|
||||
export const teamKeys = {
|
||||
list: ['teams', 'list'] as const,
|
||||
details: (id: string) => ['teams', 'details', id] as const,
|
||||
stats: (id: string) => ['teams', 'stats', id] as const,
|
||||
matches: (id: string) => ['teams', 'matches', id] as const,
|
||||
};
|
||||
|
||||
export const teamQueries = {
|
||||
@@ -15,14 +17,17 @@ export const teamQueries = {
|
||||
queryKey: teamKeys.stats(id),
|
||||
queryFn: () => getTeamStats({ data: id }),
|
||||
}),
|
||||
matches: (id: string) => ({
|
||||
queryKey: teamKeys.matches(id),
|
||||
queryFn: () => getTeamMatches({ data: id })
|
||||
})
|
||||
};
|
||||
|
||||
export const useTeam = (id: string) =>
|
||||
useServerSuspenseQuery(teamQueries.details(id));
|
||||
|
||||
export const useTeamStats = (id: string) =>
|
||||
useServerQuery({
|
||||
...teamQueries.stats(id),
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
useServerQuery(teamQueries.stats(id));
|
||||
|
||||
export const useTeamMatches = (id: string) =>
|
||||
useServerQuery(teamQueries.matches(id));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { Match } from "../matches/types";
|
||||
|
||||
|
||||
export const listTeamInfos = createServerFn()
|
||||
@@ -55,13 +56,11 @@ export const updateTeam = createServerFn()
|
||||
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");
|
||||
@@ -78,3 +77,10 @@ export const getTeamStats = createServerFn()
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
||||
);
|
||||
|
||||
export const getTeamMatches = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import Header from "./header";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import { useTournament } from "../../queries";
|
||||
import MatchList from "@/features/matches/components/match-list";
|
||||
|
||||
interface ProfileProps {
|
||||
id: string;
|
||||
@@ -19,15 +20,12 @@ const Profile = ({ id }: ProfileProps) => {
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <Text p="md">Matches feed will go here</Text>
|
||||
content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
content: <>
|
||||
<TeamList teams={tournament.teams || []} />
|
||||
<TeamList teams={tournament.teams || []} />
|
||||
<TeamList teams={tournament.teams || []} />
|
||||
<TeamList teams={tournament.teams || []} />
|
||||
</>
|
||||
}
|
||||
];
|
||||
|
||||
@@ -9,7 +9,7 @@ const EnrollFreeAgent = () => {
|
||||
const { user } = useAuth();
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={open}>
|
||||
<Button variant="subtle" size="sm" onClick={open}>
|
||||
Enroll As Free Agent
|
||||
</Button>
|
||||
|
||||
@@ -4,14 +4,19 @@ import { useAuth } from "@/contexts/auth-context";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { useMemo, useState, useCallback } from "react";
|
||||
import TeamSelectionView from "./team-selection-view";
|
||||
import TeamForm from "@/features/teams/components/team-form";
|
||||
|
||||
const EnrollTeam = () => {
|
||||
interface EnrollTeamProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
||||
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 [showTeamForm, setShowTeamForm] = useState<boolean>(!!hasTeams);
|
||||
|
||||
const teamOptions = useMemo(() =>
|
||||
user?.teams?.map(team => ({
|
||||
@@ -21,28 +26,21 @@ const EnrollTeam = () => {
|
||||
[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}>
|
||||
<Button size="sm" onClick={open}>
|
||||
Enroll Your Team
|
||||
</Button>
|
||||
|
||||
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
|
||||
<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>
|
||||
</>
|
||||
<TeamForm close={console.log} tournamentId={tournamentId} />
|
||||
) : (
|
||||
<>
|
||||
<TeamSelectionView
|
||||
@@ -1,72 +1,81 @@
|
||||
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
||||
import { Tournament } from "../../types";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { CalendarIcon, MapPinIcon, TrophyIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
TrophyIcon,
|
||||
UsersIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const Header = ({ tournament }: { tournament: Tournament}) => {
|
||||
const tournamentStart = useMemo(() => new Date(tournament.start_time), [tournament.start_time]);
|
||||
const teamCount = useMemo(() => tournament.teams?.length || 0, [tournament.teams]);
|
||||
const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
const tournamentStart = useMemo(
|
||||
() => new Date(tournament.start_time),
|
||||
[tournament.start_time]
|
||||
);
|
||||
const teamCount = useMemo(
|
||||
() => tournament.teams?.length || 0,
|
||||
[tournament.teams]
|
||||
);
|
||||
|
||||
return (
|
||||
<Group justify="space-around" align="flex-start" w='100%'>
|
||||
<Group align="center" gap="lg">
|
||||
<Avatar
|
||||
name={tournament.name}
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
: undefined
|
||||
}
|
||||
radius="md"
|
||||
size={200}
|
||||
px='md'
|
||||
withBorder={false}
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</Avatar>
|
||||
<Stack gap="xs">
|
||||
{tournament.location && (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<MapPinIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tournament.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
return (
|
||||
<Stack align="center" gap="lg">
|
||||
<Avatar
|
||||
name={tournament.name}
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
: undefined
|
||||
}
|
||||
radius="md"
|
||||
size={200}
|
||||
px="xs"
|
||||
withBorder={false}
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</Avatar>
|
||||
<Stack gap="xs">
|
||||
{tournament.location && (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<MapPinIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tournament.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<CalendarIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tournamentStart.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
at{" "}
|
||||
{tournamentStart.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<UsersIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{teamCount} teams enrolled
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<CalendarIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{tournamentStart.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
at{" "}
|
||||
{tournamentStart.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header;
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<UsersIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{teamCount} teams enrolled
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} 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";
|
||||
import EnrollTeam from "./enroll-team";
|
||||
import EnrollFreeAgent from "./enroll-free-agent";
|
||||
import TeamListButton from "./team-list-button";
|
||||
import Header from "./header";
|
||||
|
||||
@@ -56,8 +49,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs" align="center">
|
||||
<Stack gap="xs">
|
||||
<Group mb='sm' gap="xs" align="center">
|
||||
<UsersIcon size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Enrollment
|
||||
@@ -73,10 +66,14 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{!isEnrollmentOpen && !isUserEnrolled && (
|
||||
<Text fw={600} c="dimmed" size="sm">Enrollment has been closed for this tournament.</Text>
|
||||
)}
|
||||
|
||||
{!isUserEnrolled && isEnrollmentOpen && (
|
||||
<>
|
||||
<EnrollTeam />
|
||||
<Divider label="or" />
|
||||
<EnrollTeam tournamentId={tournament.id} />
|
||||
<Divider my={0} label="or" />
|
||||
<EnrollFreeAgent />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user