diff --git a/docker-compose.yml b/docker-compose.yml index 8d3c2b7..7b7ab84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -51,6 +51,7 @@ services: - postgres environment: POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens + ACCESS_TOKEN_VALIDITY: 360000 ports: - "3567:3567" env_file: diff --git a/pb_migrations/1758379630_created_badges.js b/pb_migrations/1758379630_created_badges.js new file mode 100644 index 0000000..0714d73 --- /dev/null +++ b/pb_migrations/1758379630_created_badges.js @@ -0,0 +1,85 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1843675174", + "max": 0, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_1340419796", + "indexes": [], + "listRule": null, + "name": "badges", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_1340419796"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1758380013_updated_players.js b/pb_migrations/1758380013_updated_players.js new file mode 100644 index 0000000..b9a2f6f --- /dev/null +++ b/pb_migrations/1758380013_updated_players.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // add field + collection.fields.addAt(5, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_1340419796", + "hidden": false, + "id": "relation2029409178", + "maxSelect": 999, + "minSelect": 0, + "name": "badges", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // remove field + collection.fields.removeById("relation2029409178") + + return app.save(collection) +}) diff --git a/pb_migrations/1758385120_updated_players.js b/pb_migrations/1758385120_updated_players.js new file mode 100644 index 0000000..7314613 --- /dev/null +++ b/pb_migrations/1758385120_updated_players.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // add field + collection.fields.addAt(6, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_1340419796", + "hidden": false, + "id": "relation2813965191", + "maxSelect": 1, + "minSelect": 0, + "name": "featured_badge", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3072146508") + + // remove field + collection.fields.removeById("relation2813965191") + + return app.save(collection) +}) diff --git a/pb_migrations/1758388728_created_free_agents.js b/pb_migrations/1758388728_created_free_agents.js new file mode 100644 index 0000000..c175950 --- /dev/null +++ b/pb_migrations/1758388728_created_free_agents.js @@ -0,0 +1,84 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation2551806565", + "maxSelect": 1, + "minSelect": 0, + "name": "player", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1146066909", + "max": 0, + "min": 0, + "name": "phone", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_2929550049", + "indexes": [], + "listRule": null, + "name": "free_agents", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2929550049"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1758402128_updated_free_agents.js b/pb_migrations/1758402128_updated_free_agents.js new file mode 100644 index 0000000..cf83d40 --- /dev/null +++ b/pb_migrations/1758402128_updated_free_agents.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2929550049") + + // add field + collection.fields.addAt(3, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_340646327", + "hidden": false, + "id": "relation3177167065", + "maxSelect": 1, + "minSelect": 0, + "name": "tournament", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2929550049") + + // remove field + collection.fields.removeById("relation3177167065") + + return app.save(collection) +}) diff --git a/pb_migrations/1758402424_updated_tournaments.js b/pb_migrations/1758402424_updated_tournaments.js new file mode 100644 index 0000000..1c9449b --- /dev/null +++ b/pb_migrations/1758402424_updated_tournaments.js @@ -0,0 +1,28 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("relation1584152981") + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(11, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_3072146508", + "hidden": false, + "id": "relation1584152981", + "maxSelect": 999, + "minSelect": 0, + "name": "free_agents", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + return app.save(collection) +}) diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 1e69f6a..92266a2 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -1,7 +1,3 @@ -import "@mantine/core/styles.css"; -import "@mantine/dates/styles.css"; -import "@mantine/carousel/styles.css"; -import '@mantine/tiptap/styles.css'; import { HeadContent, Navigate, @@ -18,9 +14,12 @@ import Providers from "@/features/core/components/providers"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { HeaderConfig } from "@/features/core/types/header-config"; import { playerQueries } from "@/features/players/queries"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import FullScreenLoader from "@/components/full-screen-loader"; +import mantineCssUrl from '@mantine/core/styles.css?url' +import mantineDatesCssUrl from '@mantine/dates/styles.css?url' +import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url' +import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url' export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -61,6 +60,10 @@ export const Route = createRootRouteWithContext<{ }, { rel: "manifest", href: "/site.webmanifest" }, { rel: "icon", href: "/favicon.ico" }, + { rel: 'stylesheet', href: mantineCssUrl }, + { rel: 'stylesheet', href: mantineCarouselCssUrl }, + { rel: 'stylesheet', href: mantineDatesCssUrl }, + { rel: 'stylesheet', href: mantineTiptapCssUrl } ], }), errorComponent: (props) => { @@ -131,7 +134,6 @@ function RootDocument({ children }: { children: React.ReactNode }) { >
{children}
- ); diff --git a/src/app/routes/_authed/index.tsx b/src/app/routes/_authed/index.tsx index aa9c643..debcd79 100644 --- a/src/app/routes/_authed/index.tsx +++ b/src/app/routes/_authed/index.tsx @@ -3,9 +3,13 @@ import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/ import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import StartedTournament from "@/features/tournaments/components/started-tournament"; +import { Suspense } from "react"; +import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton"; export const Route = createFileRoute("/_authed/")({ - component: Home, + component: () => }> + + , beforeLoad: async ({ context }) => { const queryClient = context.queryClient; const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current()) @@ -18,6 +22,7 @@ export const Route = createFileRoute("/_authed/")({ title: context.tournament.name || "FLXN" } }), + pendingComponent: () => }); function Home() { diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx index a24cb0c..8f67a78 100644 --- a/src/app/routes/_authed/stats.tsx +++ b/src/app/routes/_authed/stats.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute("/_authed/stats")({ component: Stats, beforeLoad: async ({ context }) => { const queryClient = context.queryClient; - await ensureServerQueryData(queryClient, playerQueries.allStats()); + ensureServerQueryData(queryClient, playerQueries.allStats()); }, loader: () => ({ withPadding: false, diff --git a/src/app/routes/_authed/teams.$teamId.tsx b/src/app/routes/_authed/teams.$teamId.tsx index e4e796c..f758276 100644 --- a/src/app/routes/_authed/teams.$teamId.tsx +++ b/src/app/routes/_authed/teams.$teamId.tsx @@ -1,8 +1,7 @@ import TeamProfile from "@/features/teams/components/team-profile"; import { teamKeys, teamQueries } from "@/features/teams/queries"; -import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; -import { redirect, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; const searchSchema = z.object({ diff --git a/src/components/rich-text-editor.tsx b/src/components/rich-text-editor.tsx index a08103c..6b1bafa 100644 --- a/src/components/rich-text-editor.tsx +++ b/src/components/rich-text-editor.tsx @@ -14,6 +14,7 @@ export function RichTextEditor({ const editor = useEditor({ extensions: [StarterKit], content: value, + immediatelyRender: false, onUpdate: ({ editor }) => { onChange(editor.getHTML()); }, diff --git a/src/components/sheet/drawer.tsx b/src/components/sheet/drawer.tsx index aa169c2..24cb70f 100644 --- a/src/components/sheet/drawer.tsx +++ b/src/components/sheet/drawer.tsx @@ -63,7 +63,7 @@ const Drawer: React.FC = ({ - + = ({ children }) => { user: data?.user || defaultAuthData.user, metadata: data?.metadata || defaultAuthData.metadata, roles: data?.roles || defaultAuthData.roles, + phone: data?.phone || "", set, }), [data, defaultAuthData] diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index f138c30..e56e452 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -107,7 +107,7 @@ const MatchCard = ({ match }: MatchCardProps) => { {match.home?.players.map((p) => ( - + {p.first_name} {p.last_name} ))} @@ -163,7 +163,7 @@ const MatchCard = ({ match }: MatchCardProps) => { {match.away?.players.map((p) => ( - + {p.first_name} {p.last_name} ))} diff --git a/src/features/players/server.ts b/src/features/players/server.ts index 4728925..6d95b0b 100644 --- a/src/features/players/server.ts +++ b/src/features/players/server.ts @@ -21,11 +21,18 @@ export const fetchMe = createServerFn() return { user: result || undefined, roles: context.roles, - metadata: context.metadata + metadata: context.metadata, + phone: context.phone }; } catch (error: any) { - logger.info('fetchMe: Session error', error.message); - return { user: undefined, roles: [], metadata: {} }; + logger.info("FetchMe: Session error", error) + if (error?.response?.status === 401) { + const errorData = error?.response?.data; + if (errorData?.error === "SESSION_REFRESH_REQUIRED") { + throw error; + } + } + return { user: undefined, roles: [], metadata: {}, phone: undefined }; } }) ); @@ -146,4 +153,4 @@ export const getUnenrolledPlayers = createServerFn() .middleware([superTokensFunctionMiddleware]) .handler(async ({ data: tournamentId }) => toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId)) - ); \ No newline at end of file + ); diff --git a/src/features/reactions/components/emoji-bar.tsx b/src/features/reactions/components/emoji-bar.tsx index 39a5f29..396e643 100644 --- a/src/features/reactions/components/emoji-bar.tsx +++ b/src/features/reactions/components/emoji-bar.tsx @@ -58,15 +58,12 @@ const EmojiBar = ({ return reaction.players.map(p => p.id).includes(user?.id || ""); }, [user?.id]); - // Get emojis the current user has reacted to const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || []; if (!reactions) return; - // Sort reactions by count (descending) const sortedReactions = [...reactions].sort((a, b) => b.count - a.count); - // Group reactions: show first 3, group the rest const visibleReactions = sortedReactions.slice(0, 3); const groupedReactions = sortedReactions.slice(3); diff --git a/src/features/reactions/queries.ts b/src/features/reactions/queries.ts index f92ee60..485b1f1 100644 --- a/src/features/reactions/queries.ts +++ b/src/features/reactions/queries.ts @@ -1,4 +1,4 @@ -import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks"; +import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server"; import { useQueryClient } from "@tanstack/react-query"; @@ -14,7 +14,7 @@ export const reactionQueries = { }; export const useMatchReactions = (matchId: string) => - useServerQuery(reactionQueries.match(matchId)); + useServerSuspenseQuery(reactionQueries.match(matchId)); export const useToggleMatchReaction = (matchId: string) => { const queryClient = useQueryClient(); @@ -27,4 +27,4 @@ export const useToggleMatchReaction = (matchId: string) => { }); }, }); -}; \ No newline at end of file +}; diff --git a/src/features/teams/components/team-list.tsx b/src/features/teams/components/team-list.tsx index 83b381e..57ce65f 100644 --- a/src/features/teams/components/team-list.tsx +++ b/src/features/teams/components/team-list.tsx @@ -69,6 +69,7 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => { {teams?.map((team) => (
{ + return ( + + {/* Header skeleton */} + + + + + + + + + + + {/* Match carousel skeleton */} + + + {Array.from({ length: 2 }).map((_, index) => ( + + + {/* Match header */} + + + + + + {/* Teams */} + + + + + + + + +
+ +
+ + + + + + + +
+
+
+ ))} +
+
+ + {/* Actions section skeleton */} + + + + + + + + + +
+ ); +}; + +export default StartedTournamentSkeleton; \ No newline at end of file diff --git a/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx index 6032480..69b000c 100644 --- a/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx @@ -2,11 +2,23 @@ import Button from "@/components/button"; import Sheet from "@/components/sheet/sheet"; import { useAuth } from "@/contexts/auth-context"; import { useSheet } from "@/hooks/use-sheet"; -import { Text } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; +import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent"; -const EnrollFreeAgent = () => { +const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => { const { open, isOpen, toggle } = useSheet(); - const { user } = useAuth(); + const { user, phone } = useAuth(); + + const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent(); + const handleEnroll = () => { + console.log('enrolling...') + enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, { + onSuccess: () => { + toggle(); + } + }); + } + return ( <> - - Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet. - - - 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. - - + + + Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet. + + + You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents. + + + Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot. + + + + ); diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx new file mode 100644 index 0000000..e643940 --- /dev/null +++ b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx @@ -0,0 +1,95 @@ +import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core"; +import { UserIcon, PhoneIcon } from "@phosphor-icons/react"; +import { useFreeAgents } from "../../queries"; +import UnenrollFreeAgent from "./unenroll-free-agent"; +import toast from "@/lib/sonner"; + +const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({ + tournamentId +}) => { + const { data: freeAgents } = useFreeAgents(tournamentId); + + const copyToClipboard = async (phone: string) => { + try { + await navigator.clipboard.writeText(phone); + toast.success("Phone number copied!"); + } catch (err) { + toast.success("Failed to copy"); + } + }; + + return ( + + + + + + Enrolled as Free Agent + + + + + + You're on the free agent list. Other free agents looking for teams: + + + {freeAgents.length > 1 ? ( + + + {freeAgents + .filter(agent => agent.player) + .map((agent) => ( + + + + {agent.player?.first_name} {agent.player?.last_name} + + + {agent.phone && ( + + copyToClipboard(agent.phone!)} + style={{ cursor: 'pointer' }} + > + + + copyToClipboard(agent.phone!)} + > + {agent.phone} + + + )} + + ))} + + {freeAgents.length > 1 && ( + + {freeAgents.length} free agents total + + )} + + + ) : ( + + + You're the only free agent so far + + + )} + + + ); +}; + +export default EnrolledFreeAgent; \ No newline at end of file diff --git a/src/features/tournaments/components/upcoming-tournament/header.tsx b/src/features/tournaments/components/upcoming-tournament/header.tsx index 6c322db..95cf2d7 100644 --- a/src/features/tournaments/components/upcoming-tournament/header.tsx +++ b/src/features/tournaments/components/upcoming-tournament/header.tsx @@ -14,10 +14,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => { () => new Date(tournament.start_time), [tournament.start_time] ); - const teamCount = useMemo( - () => tournament.teams?.length || 0, - [tournament.teams] - ); return ( diff --git a/src/features/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx index e56a4c0..791e65b 100644 --- a/src/features/tournaments/components/upcoming-tournament/index.tsx +++ b/src/features/tournaments/components/upcoming-tournament/index.tsx @@ -15,8 +15,9 @@ import TeamCard from "@/features/teams/components/team-card"; import UpdateTeam from "./update-team"; import UnenrollTeam from "./unenroll-team"; import { useQueryClient } from "@tanstack/react-query"; -import { tournamentKeys } from "../../queries"; +import { tournamentKeys, useFreeAgents } from "../../queries"; import RulesListButton from "./rules-list-button"; +import EnrolledFreeAgent from "./enrolled-free-agent"; const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ tournament, @@ -40,57 +41,79 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ const queryClient = useQueryClient(); const handleSubmit = () => { - queryClient.invalidateQueries({ queryKey: tournamentKeys.current }) - } + queryClient.invalidateQueries({ queryKey: tournamentKeys.current }); + }; + + const { data: free_agents } = useFreeAgents(tournament.id); + const isFreeAgent = useMemo(() => !isUserEnrolled && free_agents.find(a => a.player?.id === user?.id), [free_agents, isUserEnrolled]); return (
- {tournament.desc && {tournament.desc}} - - - - - - Enrollment - - {isEnrollmentOpen && ( - - - + + {tournament.desc && {tournament.desc}} + + + + + + + Enrollment + + {isEnrollmentOpen && ( + + + + )} + + + {!isUserEnrolled && !isEnrollmentOpen && ( + + Enrollment has been closed for this tournament. + )} - - {!isUserEnrolled &&!isEnrollmentOpen && ( - - Enrollment has been closed for this tournament. - - )} + {!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && ( + <> + + + + + )} - {!isUserEnrolled &&isEnrollmentOpen && ( - <> - - - - - )} + {isUserEnrolled && ( + <> + }> + + + + {isEnrollmentOpen && ( + + )} + + )} - { - isUserEnrolled && <> - }> - - - - { isEnrollmentOpen && } - - } - - + { + isFreeAgent && isEnrollmentOpen && ( + + ) + } + + + + @@ -102,10 +125,10 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ /> )} + label={`View Bracket`} + to={`/tournaments/${tournament.id}/bracket`} + Icon={TreeStructureIcon} + /> diff --git a/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx index 23791c6..84930b4 100644 --- a/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx +++ b/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx @@ -20,6 +20,7 @@ const RulesListButton: React.FC = ({ tournamentId }) => { extensions: [StarterKit], content: tournament?.rules || '', editable: false, + immediatelyRender: false, }); return ( diff --git a/src/features/tournaments/components/upcoming-tournament/skeleton.tsx b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx new file mode 100644 index 0000000..751078c --- /dev/null +++ b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx @@ -0,0 +1,42 @@ +import { Box, Card, Divider, Flex, Group, Skeleton, Stack } from "@mantine/core"; + +const UpcomingTournamentSkeleton = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default UpcomingTournamentSkeleton; \ No newline at end of file diff --git a/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx new file mode 100644 index 0000000..f08065e --- /dev/null +++ b/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx @@ -0,0 +1,40 @@ +import Button from "@/components/button"; +import Sheet from "@/components/sheet/sheet"; +import { useAuth } from "@/contexts/auth-context"; +import { useSheet } from "@/hooks/use-sheet"; +import { Stack, Text } from "@mantine/core"; +import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent"; + +const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => { + const { open, isOpen, toggle } = useSheet(); + const { user } = useAuth(); + + const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent(); + const handleUnenroll = () => { + unenrollFreeAgent({ playerId: user!.id, tournamentId }, { + onSuccess: () => { + toggle(); + } + }); + } + + return ( + <> + + + + + + This will remove you from the free agent list. + + + + + + + ); +}; + +export default UnenrollFreeAgent; diff --git a/src/features/tournaments/hooks/use-enroll-free-agent.ts b/src/features/tournaments/hooks/use-enroll-free-agent.ts new file mode 100644 index 0000000..db7fd53 --- /dev/null +++ b/src/features/tournaments/hooks/use-enroll-free-agent.ts @@ -0,0 +1,20 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useServerMutation } from "@/lib/tanstack-query/hooks"; +import { enrollFreeAgent } from "@/features/tournaments/server"; +import { tournamentKeys } from "../queries"; + +const useEnrollFreeAgent = () => { + const queryClient = useQueryClient(); + + return useServerMutation({ + mutationFn: (data: { tournamentId: string, playerId: string, phone: string }) => { + return enrollFreeAgent({ data }); + }, + onSuccess: (data, { tournamentId }) => { + queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) }); + }, + successMessage: 'You\'ve been added as a free agent!', + }); +}; + +export default useEnrollFreeAgent; diff --git a/src/features/tournaments/hooks/use-unenroll-free-agent.ts b/src/features/tournaments/hooks/use-unenroll-free-agent.ts new file mode 100644 index 0000000..b216d9a --- /dev/null +++ b/src/features/tournaments/hooks/use-unenroll-free-agent.ts @@ -0,0 +1,20 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useServerMutation } from "@/lib/tanstack-query/hooks"; +import { unenrollFreeAgent } from "@/features/tournaments/server"; +import { tournamentKeys } from "../queries"; + +const useUnenrollFreeAgent = () => { + const queryClient = useQueryClient(); + + return useServerMutation({ + mutationFn: (data: { tournamentId: string, playerId: string }) => { + return unenrollFreeAgent({ data }); + }, + onSuccess: (data, { tournamentId }) => { + queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) }); + }, + successMessage: 'You\'ve been removed as a free agent.', + }); +}; + +export default useUnenrollFreeAgent; diff --git a/src/features/tournaments/queries.ts b/src/features/tournaments/queries.ts index 8e00914..6393af5 100644 --- a/src/features/tournaments/queries.ts +++ b/src/features/tournaments/queries.ts @@ -1,11 +1,12 @@ -import { getCurrentTournament, getTournament, getUnenrolledTeams, listTournaments } from "./server"; +import { getCurrentTournament, getFreeAgents, getTournament, getUnenrolledTeams, listTournaments } from "./server"; import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; export const tournamentKeys = { list: ['tournaments', 'list'] as const, details: (id: string) => ['tournaments', 'details', id] as const, current: ['tournaments', 'current'] as const, - unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const + unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const, + free_agents: (id: string) => ['tournaments', 'free_agents', id] as const }; export const tournamentQueries = { @@ -24,6 +25,10 @@ export const tournamentQueries = { unenrolled: (id: string) => ({ queryKey: tournamentKeys.unenrolled(id), queryFn: () => getUnenrolledTeams({ data: id }) + }), + free_agents: (id: string) => ({ + queryKey: tournamentKeys.free_agents(id), + queryFn: () => getFreeAgents({ data: id }) }) }; @@ -38,3 +43,6 @@ export const useCurrentTournament = () => export const useUnenrolledTeams = (tournamentId: string) => useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId)); + +export const useFreeAgents = (tournamentId: string) => + useServerSuspenseQuery(tournamentQueries.free_agents(tournamentId)); diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts index 8013567..b5f4aa3 100644 --- a/src/features/tournaments/server.ts +++ b/src/features/tournaments/server.ts @@ -83,4 +83,39 @@ export const getUnenrolledTeams = createServerFn() .middleware([superTokensAdminFunctionMiddleware]) .handler(async ({ data: tournamentId }) => toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId)) - ); \ No newline at end of file + ); + +export const getFreeAgents = createServerFn() + .validator(z.string()) + .middleware([superTokensAdminFunctionMiddleware]) + .handler(async ({ data: tournamentId }) => + toServerResult(() => pbAdmin.getFreeAgents(tournamentId)) + ); + +export const enrollFreeAgent = createServerFn() + .validator(z.object({ phone: z.string(), tournamentId: z.string() })) + .middleware([superTokensFunctionMiddleware]) + .handler(async ({ context, data }) => + toServerResult(async () => { + const userAuthId = context.userAuthId; + const player = await pbAdmin.getPlayerByAuthId(userAuthId); + if (!player) throw new Error("Player not found"); + + await pbAdmin.enrollFreeAgent(player.id, data.phone, data.tournamentId); + logger.info('Player enrolled as free agent', { playerId: player.id, phone: data.phone }); + }) + ); + +export const unenrollFreeAgent = createServerFn() + .validator(z.object({ tournamentId: z.string() })) + .middleware([superTokensFunctionMiddleware]) + .handler(async ({ context, data }) => + toServerResult(async () => { + const userAuthId = context.userAuthId; + const player = await pbAdmin.getPlayerByAuthId(userAuthId); + if (!player) throw new Error("Player not found"); + + await pbAdmin.unenrollFreeAgent(player.id, data.tournamentId); + logger.info('Player unenrolled as free agent', { playerId: player.id }); + }) + ); diff --git a/src/hooks/use-server-events.ts b/src/hooks/use-server-events.ts index f239d85..a1364d2 100644 --- a/src/hooks/use-server-events.ts +++ b/src/hooks/use-server-events.ts @@ -2,12 +2,9 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Logger } from "@/lib/logger"; import { useAuth } from "@/contexts/auth-context"; -import { tournamentKeys, tournamentQueries } from "@/features/tournaments/queries"; +import { tournamentQueries } from "@/features/tournaments/queries"; import { reactionKeys, reactionQueries } from "@/features/reactions/queries"; -let newIdeasAvailable = false; -let newIdeasCallbacks: (() => void)[] = []; - const logger = new Logger('ServerEvents'); type SSEEvent = { @@ -19,45 +16,19 @@ type EventHandler = (event: SSEEvent, queryClient: ReturnType = { "connected": () => { - logger.info("ServerEvents | New Connection"); + logger.info("New Connection"); }, - "ping": () => {}, - - "test": (event, queryClient) => { - - }, - "match": (event, queryClient) => { queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId)) queryClient.invalidateQueries(tournamentQueries.current()) }, - "reaction": (event, queryClient) => { queryClient.invalidateQueries(reactionQueries.match(event.matchId)); queryClient.setQueryData(reactionKeys.match(event.matchId), () => event.reactions); } - }; -export function getNewIdeasAvailable(): boolean { - return newIdeasAvailable; -} - -export function clearNewIdeasAvailable(): void { - newIdeasAvailable = false; -} - -export function subscribeToNewIdeas(callback: () => void): () => void { - newIdeasCallbacks.push(callback); - return () => { - const index = newIdeasCallbacks.indexOf(callback); - if (index > -1) { - newIdeasCallbacks.splice(index, 1); - } - }; -} - export function useServerEvents() { const queryClient = useQueryClient(); const { user } = useAuth(); diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts index e1652b8..17f7001 100644 --- a/src/lib/pocketbase/services/players.ts +++ b/src/lib/pocketbase/services/players.ts @@ -105,13 +105,11 @@ export function createPlayersService(pb: PocketBase) { async getUnenrolledPlayers(tournamentId: string): Promise { try { - // Get the tournament with its enrolled teams const tournament = await pb.collection("tournaments").getOne(tournamentId, { fields: "teams", expand: "teams,teams.players" }); - // Extract player IDs from all enrolled teams const enrolledPlayerIds: string[] = []; if (tournament.expand?.teams) { const teams = Array.isArray(tournament.expand.teams) ? tournament.expand.teams : [tournament.expand.teams]; @@ -125,7 +123,6 @@ export function createPlayersService(pb: PocketBase) { }); } - // If no players are enrolled, return all players if (enrolledPlayerIds.length === 0) { const allPlayers = await pb.collection("players").getFullList({ fields: "id,first_name,last_name,email", @@ -133,7 +130,6 @@ export function createPlayersService(pb: PocketBase) { return allPlayers.map(transformPlayer); } - // Build filter to exclude enrolled players const filter = enrolledPlayerIds .map((playerId: string) => `id != "${playerId}"`) .join(" && "); @@ -146,7 +142,6 @@ export function createPlayersService(pb: PocketBase) { return availablePlayers.map(transformPlayer); } catch (error) { console.error("Error getting unenrolled players:", error); - // Fallback to all players if there's an error const allPlayers = await pb.collection("players").getFullList({ fields: "id,first_name,last_name,email", }); diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts index 9e00a5a..05b3877 100644 --- a/src/lib/pocketbase/services/tournaments.ts +++ b/src/lib/pocketbase/services/tournaments.ts @@ -7,8 +7,10 @@ import type { } from "@/features/tournaments/types"; import type { Team } from "@/features/teams/types"; import PocketBase from "pocketbase"; -import { transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types"; +import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types"; import { transformTeam } from "@/lib/pocketbase/util/transform-types"; +import { getFreeAgents } from "@/features/tournaments/server"; +import { PlayerInfo } from "@/features/players/types"; export function createTournamentsService(pb: PocketBase) { return { @@ -133,5 +135,33 @@ export function createTournamentsService(pb: PocketBase) { throw error; } }, + async enrollFreeAgent(playerId: string, phone: string, tournamentId: string): Promise { + await pb.collection("free_agents").create({ + tournament: tournamentId, + player: playerId, + phone: phone + }); + }, + + async unenrollFreeAgent(playerId: string, tournamentId: string): Promise { + const result = await pb.collection("free_agents").getFirstListItem( + `player = "${playerId}" && tournament = "${tournamentId}"` + ); + await pb.collection("free_agents").delete(result.id); + }, + async getFreeAgents(tournamentId: string): Promise<{ id: string, phone: string, player: PlayerInfo | undefined }[]> { + try { + const free_agents = await pb + .collection("free_agents") + .getFullList({ filter: `tournament = "${tournamentId}"`, + expand: 'player' + }); + + return free_agents.map(transformFreeAgent); + } catch (error) { + logger.error("PocketBase | Error getting unenrolled teams", error); + throw error; + } + }, }; } diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index 31b950d..2c44ed6 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -85,6 +85,22 @@ export function transformPlayer(record: any): Player { }; } +export function transformFreeAgent(record: any) { + const player = record.expand?.player ? transformPlayerInfo(record.expand.player) : undefined; + const tournaments = + record.expand?.tournaments + ?.sort((a: any, b: any) => + new Date(a.created!) < new Date(b.created!) ? -1 : 0 + ) + ?.map(transformTournamentInfo) ?? []; + + return { + id: record.id as string, + phone: record.phone as string, + player + }; +} + export function transformTeam(record: any): Team { const players = record.expand?.players diff --git a/src/lib/supertokens/recipes/start-session.ts b/src/lib/supertokens/recipes/start-session.ts index 9ba9500..f2c6bf1 100644 --- a/src/lib/supertokens/recipes/start-session.ts +++ b/src/lib/supertokens/recipes/start-session.ts @@ -1,27 +1,32 @@ import { getSessionForSSR } from "supertokens-node/custom"; import { ensureSuperTokensBackend } from "../server"; import { logger } from "../"; +import SuperTokens from "supertokens-node"; export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) { ensureSuperTokensBackend(); - try { const session = await getSessionForSSR(request); if (session.hasToken) { - if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) { + const userId = session.accessTokenPayload?.sub; + if (userId === undefined || session.accessTokenPayload?.sessionHandle === undefined) { return { hasToken: true, needsRefresh: true, error: 'TRY_REFRESH_TOKEN' } } + + const user = await SuperTokens.getUser(userId); + return { hasToken: true, accessTokenPayload: session.accessTokenPayload, - userId: session.accessTokenPayload?.sub, + userId, sessionHandle: session.accessTokenPayload?.sessionHandle, + phone: user?.phoneNumbers[0] }; } diff --git a/src/utils/supertokens.ts b/src/utils/supertokens.ts index 8cca6b4..4c4a912 100644 --- a/src/utils/supertokens.ts +++ b/src/utils/supertokens.ts @@ -60,6 +60,7 @@ export const verifySuperTokensSession = async ( userAuthId, roles, metadata, + phone: session.phone, session: { accessTokenPayload: session.accessTokenPayload, sessionHandle: session.sessionHandle, @@ -68,7 +69,7 @@ export const verifySuperTokensSession = async ( }; }; -export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }): Promise => { +export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }) => { const session = await verifySuperTokensSession(request); if (session.context.session?.tryRefresh) { @@ -93,6 +94,7 @@ export const getSessionContext = async (request: Request, options?: { isServerFu userAuthId: session.context.userAuthId, roles: session.context.roles, metadata: session.context.metadata, + phone: session.context.phone }; return context;