upcoming tournament page, minor changes
This commit is contained in:
28
pb_migrations/1757386414_updated_players.js
Normal file
28
pb_migrations/1757386414_updated_players.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3072146508")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1757386423_updated_matches.js
Normal file
28
pb_migrations/1757386423_updated_matches.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1757386431_updated_teams.js
Normal file
28
pb_migrations/1757386431_updated_teams.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
28
pb_migrations/1757386438_updated_tournaments.js
Normal file
28
pb_migrations/1757386438_updated_tournaments.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "",
|
||||||
|
"listRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"viewRule": ""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
|
|||||||
import Layout from "@/features/core/components/layout";
|
import Layout from "@/features/core/components/layout";
|
||||||
import { useServerEvents } from "@/hooks/use-server-events";
|
import { useServerEvents } from "@/hooks/use-server-events";
|
||||||
import { Loader } from "@mantine/core";
|
import { Loader } from "@mantine/core";
|
||||||
|
import FullScreenLoader from "@/components/full-screen-loader";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed")({
|
export const Route = createFileRoute("/_authed")({
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
@@ -26,7 +27,7 @@ export const Route = createFileRoute("/_authed")({
|
|||||||
},
|
},
|
||||||
pendingComponent: () => (
|
pendingComponent: () => (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Loader size="xl" />
|
<FullScreenLoader />
|
||||||
</Layout>
|
</Layout>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { TrophyIcon } from "@phosphor-icons/react";
|
import { useCurrentTournament } from "@/features/tournaments/queries";
|
||||||
import ListLink from "@/components/list-link";
|
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
|
||||||
import { Box, Divider, Text } from "@mantine/core";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/")({
|
export const Route = createFileRoute("/_authed/")({
|
||||||
component: Home,
|
component: Home,
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
withPadding: false
|
withPadding: true,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
return (
|
const { data: tournament } = useCurrentTournament();
|
||||||
<>
|
const now = new Date();
|
||||||
<Box h='60vh' p="md">
|
|
||||||
<Text m='16vh' fw={500}>Some Content Here</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
if (new Date(tournament.start_time) > now) {
|
||||||
<Text pl='md'>Quick Links</Text>
|
return <UpcomingTournament tournament={tournament} />;
|
||||||
<Divider />
|
}
|
||||||
<ListLink label="All Tournaments" to="/tournaments" Icon={TrophyIcon} />
|
|
||||||
</Box>
|
return <p>Started Tournament</p>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/components/countdown.tsx
Normal file
57
src/components/countdown.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import useNow from '@/hooks/use-now';
|
||||||
|
import { Text, Group } from '@mantine/core';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
interface CountdownProps {
|
||||||
|
date: Date;
|
||||||
|
label?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeLeft {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft {
|
||||||
|
const difference = targetDate.getTime() - currentTime.getTime();
|
||||||
|
|
||||||
|
if (difference <= 0) {
|
||||||
|
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
|
||||||
|
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
|
||||||
|
minutes: Math.floor((difference / 1000 / 60) % 60),
|
||||||
|
seconds: Math.floor((difference / 1000) % 60)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Countdown({ date, label, color }: CountdownProps) {
|
||||||
|
const now = useNow();
|
||||||
|
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
||||||
|
|
||||||
|
const formatTime = () => {
|
||||||
|
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
if (timeLeft.days > 0) {
|
||||||
|
return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||||
|
} else {
|
||||||
|
return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
{label && <Text size='sm' fw={500}>{label}:</Text>}
|
||||||
|
<Text size='sm' fw={600} c={color} ff="monospace">
|
||||||
|
{formatTime()}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Countdown;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Box, Container, useComputedColorScheme } from "@mantine/core";
|
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
|
||||||
import { PropsWithChildren, useEffect } from "react";
|
import { PropsWithChildren, Suspense, useEffect } from "react";
|
||||||
import { Drawer as VaulDrawer } from "vaul";
|
import { Drawer as VaulDrawer } from "vaul";
|
||||||
import { useMantineColorScheme } from "@mantine/core";
|
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
import FullScreenLoader from "../full-screen-loader";
|
||||||
|
|
||||||
interface DrawerProps extends PropsWithChildren {
|
interface DrawerProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -76,7 +76,13 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
|
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
|
||||||
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
||||||
{children}
|
<Suspense fallback={
|
||||||
|
<Flex justify='center' align='center' w='100%' h={400}>
|
||||||
|
<Loader size='lg' />
|
||||||
|
</Flex>
|
||||||
|
}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
</Container>
|
</Container>
|
||||||
</Container>
|
</Container>
|
||||||
</VaulDrawer.Content>
|
</VaulDrawer.Content>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Modal as MantineModal, Title } from "@mantine/core";
|
import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren, Suspense } from "react";
|
||||||
|
|
||||||
interface ModalProps extends PropsWithChildren {
|
interface ModalProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -13,7 +13,15 @@ const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={<Title order={3}>{title}</Title>}
|
title={<Title order={3}>{title}</Title>}
|
||||||
>
|
>
|
||||||
{children}
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Flex justify="center" align="center" w="100%" h={400}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
</MantineModal>
|
</MantineModal>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface SlidePanelProps {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
submitText?: string;
|
submitText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
cancelColor?: string;
|
||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
formProps?: Record<string, any>;
|
formProps?: Record<string, any>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -28,6 +29,7 @@ const SlidePanel = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
submitText = "Submit",
|
submitText = "Submit",
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
|
cancelColor = "red",
|
||||||
maxHeight = "70vh",
|
maxHeight = "70vh",
|
||||||
formProps = {},
|
formProps = {},
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -114,7 +116,7 @@ const SlidePanel = ({
|
|||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color={cancelColor}
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showToolbar = useMemo(
|
const showToolbar = useMemo(
|
||||||
() => match.home && match.away,
|
() => match.home && match.away && onAnnounce,
|
||||||
[match.home, match.away]
|
[match.home, match.away]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
229
src/features/teams/components/team-form.tsx
Normal file
229
src/features/teams/components/team-form.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
43
src/features/teams/hooks/use-available-players.ts
Normal file
43
src/features/teams/hooks/use-available-players.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
22
src/features/teams/hooks/use-create-team.ts
Normal file
22
src/features/teams/hooks/use-create-team.ts
Normal 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;
|
||||||
17
src/features/teams/hooks/use-update-team.ts
Normal file
17
src/features/teams/hooks/use-update-team.ts
Normal 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;
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware, superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
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()
|
export const getTeam = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
@@ -10,3 +24,74 @@ export const getTeam = createServerFn()
|
|||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeam(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))
|
||||||
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface TeamInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
primary_color: string;
|
primary_color: string;
|
||||||
accent_color: string;
|
accent_color: string;
|
||||||
|
logo?: string;
|
||||||
players: PlayerInfo[];
|
players: PlayerInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +51,38 @@ export const teamInputSchema = z
|
|||||||
song_start: z.number().int().optional(),
|
song_start: z.number().int().optional(),
|
||||||
song_end: z.number().int().optional(),
|
song_end: z.number().int().optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").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(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -62,4 +95,4 @@ export const teamInputSchema = z
|
|||||||
);
|
);
|
||||||
|
|
||||||
export type TeamInput = z.infer<typeof teamInputSchema>;
|
export type TeamInput = z.infer<typeof teamInputSchema>;
|
||||||
export type TeamUpdateInput = Partial<TeamInput>;
|
export type TeamUpdateInput = z.infer<typeof teamUpdateSchema>;
|
||||||
|
|||||||
29
src/features/tournaments/components/enroll-free-agent.tsx
Normal file
29
src/features/tournaments/components/enroll-free-agent.tsx
Normal 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;
|
||||||
63
src/features/tournaments/components/enroll-team/index.tsx
Normal file
63
src/features/tournaments/components/enroll-team/index.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -10,8 +10,8 @@ import toast from "@/lib/sonner";
|
|||||||
import { logger } from "..";
|
import { logger } from "..";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { DateTimePicker } from "@/components/date-time-picker";
|
||||||
|
|
||||||
interface TournamentFormProps {
|
interface TournamentFormProps {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -166,6 +166,7 @@ const TournamentForm = ({
|
|||||||
label="Start Date"
|
label="Start Date"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
formatValue={(date) =>
|
formatValue={(date) =>
|
||||||
|
!date ? 'Select a time' :
|
||||||
new Date(date).toLocaleDateString("en-US", {
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -186,6 +187,7 @@ const TournamentForm = ({
|
|||||||
label="Enrollment Due"
|
label="Enrollment Due"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
formatValue={(date) =>
|
formatValue={(date) =>
|
||||||
|
!date ? 'Select a time' :
|
||||||
new Date(date).toLocaleDateString("en-US", {
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -206,17 +208,16 @@ const TournamentForm = ({
|
|||||||
title="Select End Date"
|
title="Select End Date"
|
||||||
label="End Date (Optional)"
|
label="End Date (Optional)"
|
||||||
formatValue={(date) =>
|
formatValue={(date) =>
|
||||||
date
|
!date ? 'Select a time' :
|
||||||
? new Date(date).toLocaleDateString("en-US", {
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
hour12: true,
|
hour12: true,
|
||||||
})
|
})
|
||||||
: "Not set"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
146
src/features/tournaments/components/upcoming-tournament.tsx
Normal file
146
src/features/tournaments/components/upcoming-tournament.tsx
Normal 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;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
import { getCurrentTournament, getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
||||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
export const tournamentKeys = {
|
export const tournamentKeys = {
|
||||||
list: ['tournaments', 'list'] as const,
|
list: ['tournaments', 'list'] as const,
|
||||||
details: (id: string) => ['tournaments', 'details', id] as const,
|
details: (id: string) => ['tournaments', 'details', id] as const,
|
||||||
|
current: ['tournaments', 'current'] as const,
|
||||||
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ export const tournamentQueries = {
|
|||||||
queryKey: tournamentKeys.details(id),
|
queryKey: tournamentKeys.details(id),
|
||||||
queryFn: () => getTournament({ data: id })
|
queryFn: () => getTournament({ data: id })
|
||||||
}),
|
}),
|
||||||
|
current: () => ({
|
||||||
|
queryKey: tournamentKeys.current,
|
||||||
|
queryFn: getCurrentTournament
|
||||||
|
}),
|
||||||
unenrolled: (id: string) => ({
|
unenrolled: (id: string) => ({
|
||||||
queryKey: tournamentKeys.unenrolled(id),
|
queryKey: tournamentKeys.unenrolled(id),
|
||||||
queryFn: () => getUnenrolledTeams({ data: id })
|
queryFn: () => getUnenrolledTeams({ data: id })
|
||||||
@@ -28,5 +33,8 @@ export const useTournaments = () =>
|
|||||||
export const useTournament = (id: string) =>
|
export const useTournament = (id: string) =>
|
||||||
useServerSuspenseQuery(tournamentQueries.details(id));
|
useServerSuspenseQuery(tournamentQueries.details(id));
|
||||||
|
|
||||||
|
export const useCurrentTournament = () =>
|
||||||
|
useServerSuspenseQuery(tournamentQueries.current());
|
||||||
|
|
||||||
export const useUnenrolledTeams = (tournamentId: string) =>
|
export const useUnenrolledTeams = (tournamentId: string) =>
|
||||||
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
|
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export const getTournament = createServerFn()
|
|||||||
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getCurrentTournament = createServerFn()
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async () =>
|
||||||
|
toServerResult(() => pbAdmin.getMostRecentTournament())
|
||||||
|
);
|
||||||
|
|
||||||
export const enrollTeam = createServerFn()
|
export const enrollTeam = createServerFn()
|
||||||
.validator(z.object({
|
.validator(z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
|
|||||||
18
src/hooks/use-now.ts
Normal file
18
src/hooks/use-now.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const useNow = () => {
|
||||||
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
setNow(new Date());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default useNow;
|
||||||
@@ -26,6 +26,7 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
||||||
const result = await pb.collection("players").getList<Player>(1, 1, {
|
const result = await pb.collection("players").getList<Player>(1, 1, {
|
||||||
filter: `auth_id = "${authId}"`,
|
filter: `auth_id = "${authId}"`,
|
||||||
|
expand: 'teams'
|
||||||
});
|
});
|
||||||
return result.items[0] ? transformPlayer(result.items[0]) : null;
|
return result.items[0] ? transformPlayer(result.items[0]) : null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
|
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
|
||||||
import { Team, TeamInfo } from "@/features/teams/types";
|
import { Team, TeamInfo, TeamInput, TeamUpdateInput } from "@/features/teams/types";
|
||||||
import { DataFetchOptions } from "./base";
|
|
||||||
|
|
||||||
export function createTeamsService(pb: PocketBase) {
|
export function createTeamsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getTeamInfo(id: string): Promise<TeamInfo> {
|
async getTeamInfo(id: string): Promise<TeamInfo> {
|
||||||
logger.info("PocketBase | Getting team info", id);
|
logger.info("PocketBase | Getting team info", id);
|
||||||
const result = await pb.collection("teams").getOne(id, {
|
const result = await pb.collection("teams").getOne(id, {
|
||||||
fields: "id,name,primary_color,accent_color"
|
fields: "id,name,primary_color,accent_color,logo",
|
||||||
|
expand: "players"
|
||||||
});
|
});
|
||||||
return transformTeamInfo(result);
|
return transformTeamInfo(result);
|
||||||
},
|
},
|
||||||
@@ -17,10 +17,12 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
async listTeamInfos(): Promise<TeamInfo[]> {
|
async listTeamInfos(): Promise<TeamInfo[]> {
|
||||||
logger.info("PocketBase | Listing team infos");
|
logger.info("PocketBase | Listing team infos");
|
||||||
const result = await pb.collection("teams").getFullList({
|
const result = await pb.collection("teams").getFullList({
|
||||||
fields: "id,name,primary_color,accent_color"
|
fields: "id,name,primary_color,accent_color,logo",
|
||||||
|
expand: "players"
|
||||||
});
|
});
|
||||||
return result.map(transformTeamInfo);
|
return result.map(transformTeamInfo);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getTeam(id: string): Promise<Team | null> {
|
async getTeam(id: string): Promise<Team | null> {
|
||||||
logger.info("PocketBase | Getting team", id);
|
logger.info("PocketBase | Getting team", id);
|
||||||
const result = await pb.collection("teams").getOne(id, {
|
const result = await pb.collection("teams").getOne(id, {
|
||||||
@@ -28,5 +30,56 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
return transformTeam(result);
|
return transformTeam(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createTeam(data: TeamInput): Promise<Team> {
|
||||||
|
logger.info("PocketBase | Creating team", data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const playerId of data.players) {
|
||||||
|
const playerExists = await pb.collection("players").getOne(playerId).catch(() => null);
|
||||||
|
if (!playerExists) {
|
||||||
|
throw new Error(`Player with ID ${playerId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pb.collection("teams").create({
|
||||||
|
...data,
|
||||||
|
players: data.players
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const playerId of data.players) {
|
||||||
|
await pb.collection("players").update(playerId, {
|
||||||
|
"teams+": result.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformTeam(await pb.collection("teams").getOne(result.id, {
|
||||||
|
expand: "players, tournaments"
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("PocketBase | Error creating team", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTeam(id: string, data: TeamUpdateInput): Promise<Team> {
|
||||||
|
logger.info("PocketBase | Updating team", { id, updates: Object.keys(data) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingTeam = await pb.collection("teams").getOne(id).catch(() => null);
|
||||||
|
if (!existingTeam) {
|
||||||
|
throw new Error(`Team with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pb.collection("teams").update(id, data);
|
||||||
|
|
||||||
|
return transformTeam(await pb.collection("teams").getOne(result.id, {
|
||||||
|
expand: "players, tournaments"
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("PocketBase | Error updating team", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
});
|
});
|
||||||
return transformTournament(result);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
|
async getMostRecentTournament(): Promise<Tournament> {
|
||||||
|
const result = await pb
|
||||||
|
.collection("tournaments")
|
||||||
|
.getFirstListItem('',
|
||||||
|
{
|
||||||
|
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away",
|
||||||
|
sort: "-created",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return transformTournament(result);
|
||||||
|
},
|
||||||
async listTournaments(): Promise<TournamentInfo[]> {
|
async listTournaments(): Promise<TournamentInfo[]> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
.collection("tournaments")
|
.collection("tournaments")
|
||||||
|
|||||||
Reference in New Issue
Block a user