diff --git a/src/api.ts b/src/api.ts index 4fb1a12..fd25965 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,36 +1,162 @@ -import { GameDay, Player } from "./types"; +import { GameDay, GameDayPlayer, GameGroup, Player } from "./types"; const API_URL = window.location.hostname === "localhost" ? "http://localhost:4000" : "https://plankton-app-xoik3.ondigitalocean.app"; -const putPlayer = async (player: Player) => { +const createGroup = async (name: string, players: Player[] = []) => { try { - const res = await fetch(`${API_URL}/players/`, { - body: JSON.stringify(player), + const res = await fetch(`${API_URL}/groups`, { + method: "POST", headers: { "Content-Type": "application/json", }, + body: JSON.stringify({ name, players }), credentials: "include", + }); + if (!res.ok) return null; + return (await res.json()) as { id: string; inviteCode: string }; + } catch (error) { + console.error(error); + return null; + } +}; + +const getGroups = async () => { + try { + const res = await fetch(`${API_URL}/groups`, { + credentials: "include", + }); + if (!res.ok) return []; + return (await res.json()) as GameGroup[]; + } catch (error) { + console.error(error); + return []; + } +}; + +const getGroup = async (groupId: string) => { + try { + const res = await fetch(`${API_URL}/groups/${groupId}`, { + credentials: "include", + }); + if (!res.ok) return null; + return (await res.json()) as GameGroup; + } catch (error) { + console.error(error); + return null; + } +}; + +const joinGroup = async (inviteCode: string) => { + try { + const res = await fetch(`${API_URL}/groups/join/${inviteCode}`, { method: "PUT", + credentials: "include", + }); + if (!res.ok) return null; + return (await res.json()) as GameGroup; + } catch (error) { + console.error(error); + return null; + } +}; + +const deleteGroup = async (groupId: string) => { + // É tratado como delete, mas é apenas um soft + try { + const res = await fetch(`${API_URL}/groups/${groupId}`, { + method: "DELETE", + credentials: "include", + }); + return res.ok; + } catch (error) { + console.error(error); + return false; + } +}; + +const getGroupPlayers = async (groupId: string) => { + try { + const res = await fetch(`${API_URL}/groups/${groupId}/players`, { + credentials: "include", + }); + if (!res.ok) return []; + return (await res.json()) as Player[]; + } catch (error) { + console.error(error); + return []; + } +}; + +const addGroupPlayer = async (groupId: string, player: Player) => { + try { + const res = await fetch(`${API_URL}/groups/${groupId}/players`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(player), + credentials: "include", }); return res.ok; + } catch (error) { + console.error(error); + return false; + } +}; + +const getGroupGameDays = async (groupId: string) => { + try { + const res = await fetch(`${API_URL}/groups/${groupId}/game-days`, { + credentials: "include", + }); + if (!res.ok) return []; + return (await res.json()) as GameDay[]; + } catch (error) { + console.error(error); + return []; + } +}; + +type CreateGroupGameDayParams = { + maxPoints: number; + playersPerTeam: number; + autoSwitchTeamsPoints: number; + isLive: boolean; + playedOn: Date; + players: (Player & Partial)[]; + playingTeams: GameDayPlayer[][]; +}; + +const createGroupGameDay = async (groupId: string, gameDay: CreateGroupGameDayParams) => { + try { + const res = await fetch(`${API_URL}/groups/${groupId}/game-days`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(gameDay), + credentials: "include", + }); + if (!res.ok) return null; + return (await res.json()) as { id: string; courtId: string; joinCode: string }; } catch (error) { console.error(error); return null; } }; -const updatePlayers = async (players: Player[]) => { +const putPlayer = async (player: Player) => { try { - const res = await fetch(`${API_URL}/players/bulk`, { - method: "PUT", + const res = await fetch(`${API_URL}/players/`, { + body: JSON.stringify(player), headers: { "Content-Type": "application/json", }, - body: JSON.stringify(players), credentials: "include", + method: "PUT", }); return res.ok; } catch (error) { @@ -39,16 +165,20 @@ const updatePlayers = async (players: Player[]) => { } }; -const getPlayers = async (params: URLSearchParams) => { +const updatePlayers = async (players: Player[]) => { try { - const res = await fetch(`${API_URL}/players?${params.toString()}`, { + const res = await fetch(`${API_URL}/players/bulk`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(players), credentials: "include", }); - const players = (await res.json()) as Player[]; - return players; + return res.ok; } catch (error) { console.error(error); - return []; + return null; } }; @@ -204,9 +334,22 @@ async function restartGameDay(gameId: string) { } export const api = { - getPlayers, + // Groups + createGroup, + getGroups, + getGroup, + joinGroup, + deleteGroup, + // Group Players + getGroupPlayers, + addGroupPlayer, + // Group Game Days + getGroupGameDays, + createGroupGameDay, + // Players updatePlayers, putPlayer, + // Game Days createGameDay, getActiveGameDay, updateGameDay, diff --git a/src/app.tsx b/src/app.tsx index 48b491c..9b5106a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -6,6 +6,11 @@ import History from "./pages/history/history"; import Layout from "./pages/layout"; import HistoryMatch from "./pages/history/history-match"; import EditGameDay from "./pages/game-day/edit/edit-game-day"; +import Groups from "./pages/groups/groups"; +import CreateGroup from "./pages/groups/create-group"; +import GroupDetail from "./pages/groups/group-detail"; +import CreateGroupGameDay from "./pages/groups/create-game-day/create-group-game-day"; +import GroupHistory from "./pages/groups/history/group-history"; function App() { return ( @@ -20,6 +25,14 @@ function App() { } /> } /> + + } /> + } /> + } /> + } /> + } /> + } /> + ); diff --git a/src/components/court-form-group.tsx b/src/components/court-form-group.tsx new file mode 100644 index 0000000..9e6879a --- /dev/null +++ b/src/components/court-form-group.tsx @@ -0,0 +1,235 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useNavigate } from "react-router"; +import { z } from "zod"; +import { api } from "../api"; +import { useGroupPlayers } from "../hooks/use-group-players"; +import { createInitialRating } from "../lib/elo"; +import BackButton from "./back-button"; +import Input from "./input"; +import Select from "./select"; + +const schema = z.object({ + playersPerTeam: z + .number({ coerce: true, message: "Informe um número maior que 0" }) + .min(1, { message: "Informe um número maior que 0" }), + maxPoints: z + .number({ coerce: true, message: "Informe um número maior que 0" }) + .min(1, { message: "Informe um número maior que 0" }), + autoSwitchTeams: z.boolean().optional(), + autoSwitchTeamsPoints: z.number({ coerce: true }).optional(), + players: z.array( + z.object({ + value: z.object({ name: z.string(), mu: z.number(), sigma: z.number() }), + label: z.string(), + __isNew__: z.boolean().optional(), + isFixed: z.boolean().optional(), + }), + { + message: "Informe pelo menos um jogador", + } + ), +}); + +export type CourtFormGroupData = z.infer; + +type Props = { + groupId: string; + onSubmit: (data: CourtFormGroupData) => Promise; + submitButton: (isSubmitting: boolean) => React.ReactNode; + initialValues?: CourtFormGroupData; +}; + +const CourtFormGroup = ({ groupId, onSubmit, submitButton, initialValues }: Props) => { + const { data: groupPlayers = [], mutate: refreshPlayers } = useGroupPlayers(groupId); + const navigate = useNavigate(); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: initialValues, + }); + + const handleSubmit = form.handleSubmit(async (data) => { + try { + if (data.autoSwitchTeams && !data.autoSwitchTeamsPoints) { + form.setError("autoSwitchTeamsPoints", { + type: "manual", + message: "Informe o número de pontos para trocar de time", + }); + return; + } + + if ( + data.autoSwitchTeamsPoints && + data.autoSwitchTeamsPoints >= data.maxPoints + ) { + form.setError("autoSwitchTeamsPoints", { + type: "manual", + message: + "O número de pontos para trocar de time deve ser menor que o máximo de pontos", + }); + return; + } + + if (data.playersPerTeam * 2 > data.players.length) { + form.setError("players", { + type: "manual", + message: + "O número de jogadores é menor que o número de jogadores por time", + }); + return; + } + + const gameDay = await onSubmit(data); + + if (!gameDay) { + form.setError("root", { + type: "manual", + message: "Erro ao criar jogo", + }); + return; + } + + navigate("/pelada"); + } catch (e) { + console.error(e); + } + }); + + const [isCreatingPlayer, setIsCreatingPlayer] = useState(false); + const options = + groupPlayers + ?.map((player) => ({ + value: player, + label: player.name, + })) + .sort((a, b) => a.label.localeCompare(b.label)) ?? []; + const selectedPlayers = form.watch("players"); + + const onCreateOption = async (name: string) => { + try { + form.clearErrors("players"); + setIsCreatingPlayer(true); + const newPlayer = { name, ...createInitialRating() }; + const ok = await api.addGroupPlayer(groupId, newPlayer); + if (!ok) { + form.setError("players", { + type: "manual", + message: `Erro ao criar jogador ${name}`, + }); + return; + } + await refreshPlayers(); + form.setValue("players", [ + ...(selectedPlayers ?? []), + { value: newPlayer, label: newPlayer.name }, + ]); + } finally { + setIsCreatingPlayer(false); + } + }; + + const autoSwitchTeams = form.watch("autoSwitchTeams"); + + return ( +
+ +
+
+ + + {form.formState.errors.playersPerTeam && ( + + {form.formState.errors.playersPerTeam.message} + + )} +
+
+ + + {form.formState.errors.maxPoints && ( + + {form.formState.errors.maxPoints.message} + + )} +
+
+
+
+ + + {form.formState.errors.autoSwitchTeams && ( + + {form.formState.errors.autoSwitchTeams.message} + + )} +
+
+ + + {form.formState.errors.autoSwitchTeamsPoints && ( + + {form.formState.errors.autoSwitchTeamsPoints.message} + + )} +
+
+
+
+ + ( + + {form.formState.errors.name && ( +

+ {form.formState.errors.name.message} +

+ )} +
+ + {form.formState.errors.root && ( +

+ {form.formState.errors.root.message} +

+ )} + + + +
+ + ); +}; + +export default CreateGroup; diff --git a/src/pages/groups/group-detail.tsx b/src/pages/groups/group-detail.tsx new file mode 100644 index 0000000..f4ea105 --- /dev/null +++ b/src/pages/groups/group-detail.tsx @@ -0,0 +1,159 @@ +import { useEffect } from "react"; +import { Link, useNavigate, useParams } from "react-router"; +import { + FaPlus, + FaUsers, + FaCopy, + FaClock, + FaRightToBracket, + FaTrash, +} from "react-icons/fa6"; +import { VscLoading } from "react-icons/vsc"; +import { useGroup } from "../../hooks/use-group"; +import { useGroupPlayers } from "../../hooks/use-group-players"; +import { useGroupGameDays } from "../../hooks/use-group-game-days"; +import { api } from "../../api"; +import { buttonClasses } from "../../components/button"; +import BackButton from "../../components/back-button"; + +const GroupDetail = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { data: group, isLoading } = useGroup(id || null); + const { data: players = [] } = useGroupPlayers(id || null); + const gameDays = useGroupGameDays(id || null); + + // Redirect if group not found + useEffect(() => { + if (!isLoading && !group && id) { + navigate("/grupos"); + } + }, [isLoading, group, id, navigate]); + + const copyInviteCode = () => { + if (!group) return; + navigator.clipboard.writeText(group.inviteCode); + alert("Código copiado: " + group.inviteCode); + }; + + const handleDeleteGroup = async () => { + if (!group) return; + + const confirmed = window.confirm( + "⚠️ ATENÇÃO: Isso irá apagar o grupo e todos os dados associados. Deseja continuar?" + ); + if (confirmed) { + const success = await api.deleteGroup(group.id); + if (success) { + navigate("/grupos"); + } else { + alert("Erro ao apagar o grupo. Tente novamente."); + } + } + }; + + if (isLoading || !group) { + return ( +
+ +
+ ); + } + + return ( + <> + +
+
+

+ {group.name} +

+ +
+
+ + + Criar nova pelada + + + + Histórico + +
+
+ + + Transferir/Entrar em Pelada Existente + +
+
+
+

+ Jogadores ({players.length}) +

+
+ {players.length > 0 ? ( +
    + {players + .sort((a, b) => a.name.localeCompare(b.name)) + .map((player) => ( +
  • + {player.name} +
  • + ))} +
+ ) : ( +

+ Nenhum jogador cadastrado. Crie uma pelada para adicionar jogadores! +

+ )} +
+ {gameDays.data && gameDays.data.length > 0 && ( +
+

Peladas Recentes

+
    + {gameDays.data.slice(0, 5).map((gameDay) => ( +
  • + + {new Date(gameDay.playedOn).toLocaleString()} + + {gameDay.players.length} jogadores + + +
  • + ))} +
+
+ )} + +
+ + ); +}; + +export default GroupDetail; diff --git a/src/pages/groups/groups.tsx b/src/pages/groups/groups.tsx new file mode 100644 index 0000000..64e2feb --- /dev/null +++ b/src/pages/groups/groups.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { FaPlus, FaUsers, FaRightToBracket, FaCopy, FaArrowRight } from "react-icons/fa6"; +import { VscLoading } from "react-icons/vsc"; +import { useGroups } from "../../hooks/use-groups"; +import { api } from "../../api"; +import Button, { buttonClasses } from "../../components/button"; +import Input from "../../components/input"; +import BackButton from "../../components/back-button"; +import { GameGroup } from "../../types"; + +const Groups = () => { + const groups = useGroups(); + const navigate = useNavigate(); + const [inviteCode, setInviteCode] = useState(""); + const [isJoining, setIsJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + + const handleJoinGroup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inviteCode.trim()) return; + + try { + setIsJoining(true); + setJoinError(null); + const group = await api.joinGroup(inviteCode.toUpperCase()); + if (!group) { + setJoinError("Código inválido ou expirado"); + return; + } + await groups.mutate(); + navigate("/grupos/" + group.id); + } catch { + setJoinError("Erro ao entrar no grupo"); + } finally { + setIsJoining(false); + } + }; + + const handleSelectGroup = (group: GameGroup) => { + navigate(`/grupos/${group.id}`); + }; + + const copyInviteCode = (code: string, e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(code); + alert("Código copiado!"); + }; + + if (groups.error) return
Erro ao carregar grupos
; + + if (groups.isLoading || !groups.data) { + return ( +
+ +
+ ); + } + + return ( + <> + +
+ +

+ Meus Grupos de Pelada +

+ + + Criar novo grupo + +
+
+ +
+ { + setInviteCode(e.target.value.toUpperCase()); + setJoinError(null); + }} + placeholder="Digite o código" + /> + +
+
+ {joinError &&

{joinError}

} +
+ {groups.data.length > 0 ? ( +
+

Selecione um grupo:

+
    + {groups.data.map((group) => ( +
  • + + +
+ + + ))} + +
+ ) : ( +

+ Você ainda não tem nenhum grupo. Crie um ou entre com um código de + convite! +

+ )} + + + ); +}; + +export default Groups; diff --git a/src/pages/groups/history/group-history.tsx b/src/pages/groups/history/group-history.tsx new file mode 100644 index 0000000..b4ece61 --- /dev/null +++ b/src/pages/groups/history/group-history.tsx @@ -0,0 +1,53 @@ +import { FaClock } from "react-icons/fa"; +import { VscLoading } from "react-icons/vsc"; +import { Link, useParams } from "react-router"; +import BackButton from "../../../components/back-button"; +import { useGroupGameDays } from "../../../hooks/use-group-game-days"; + +const GroupHistory = () => { + const { id: groupId } = useParams<{ id: string }>(); + const gameDays = useGroupGameDays(groupId || null); + + if (gameDays.error) return
Erro ao carregar
; + + if (gameDays.isLoading || !gameDays.data) + return ( +
+ +
+ ); + + return ( + <> + +

+ Histórico de Partidas +

+ {gameDays.data.length === 0 ? ( +

+ Nenhuma pelada registrada ainda. +

+ ) : ( +
    + {gameDays.data.map((gameDay) => { + return ( +
  • + + {new Date(gameDay.playedOn).toLocaleString()} + +
  • + ); + })} +
+ )} + + ); +}; + +export default GroupHistory; diff --git a/src/pages/history/history-match.tsx b/src/pages/history/history-match.tsx index 71f4abe..bf3b0c2 100644 --- a/src/pages/history/history-match.tsx +++ b/src/pages/history/history-match.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { useGameDays } from '../../hooks/use-game-days' +import { useGroupGameDays } from '../../hooks/use-group-game-days' import { VscLoading } from 'react-icons/vsc'; import { FaRedo } from 'react-icons/fa'; -import { useParams, useSearchParams, useNavigate } from 'react-router'; +import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router'; import PlayersTable from '../../components/players-table'; import BackButton from '../../components/back-button'; import Button from '../../components/button'; @@ -10,8 +11,19 @@ import { api } from '../../api'; const HistoryMatch = () => { - const gameDays = useGameDays(); - const params = useParams(); + // Support both legacy routes (/historico/:id) and group routes (/grupos/:id/historico/:gameDayId) + const params = useParams<{ id?: string; gameDayId?: string }>(); + const location = useLocation(); + const isGroupRoute = location.pathname.includes('/grupos/'); + + // Extract groupId and gameDayId based on route + const groupId = isGroupRoute ? params.id : null; + const gameDayId = isGroupRoute ? params.gameDayId : params.id; + + const legacyGameDays = useGameDays(); + const groupGameDays = useGroupGameDays(groupId ?? null); + + const gameDays = isGroupRoute ? groupGameDays : legacyGameDays; const [searchParams] = useSearchParams() const navigate = useNavigate(); const [isRestarting, setIsRestarting] = useState(false); @@ -22,7 +34,7 @@ const HistoryMatch = () => { } - const gameDay = gameDays.data?.find(gameDay => gameDay.id === params.id); + const gameDay = gameDays.data?.find(gd => gd.id === gameDayId); if(!gameDay) { return
{ } }; + // Determine back navigation path + const getBackPath = () => { + if (searchParams.get('origin') === 'game-day') return '/'; + if (isGroupRoute && groupId) return `/grupos/${groupId}/historico`; + return '/historico'; + }; + return ( <> - + { return (
-
- - - Criar nova pelada - + + + Meus Grupos de Pelada + +
+

+ Ou crie uma pelada avulsa: +

+
+ + + Criar nova pelada + +