feat: add tournament creation wizard with participant management and bulk game entry

This commit is contained in:
2026-03-29 19:58:15 -07:00
parent eb381d928c
commit 54fd6abb5e
10 changed files with 984 additions and 97 deletions
+311 -97
View File
@@ -1,20 +1,83 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Navigation from "@/components/Navigation"
interface Player {
id: number
name: string
currentElo: number
}
interface TournamentFormData {
name: string
description: string
eventDate: string
format: string
maxParticipants: string
participants: number[] // Array of player IDs
}
export default function NewTournamentPage() {
const router = useRouter()
const [formData, setFormData] = useState({
const [step, setStep] = useState(1)
const [formData, setFormData] = useState<TournamentFormData>({
name: "",
description: "",
eventDate: "",
format: "round_robin",
maxParticipants: "",
participants: [],
})
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
// Player search state
const [searchQuery, setSearchQuery] = useState("")
const [searchResults, setSearchResults] = useState<Player[]>([])
const [selectedPlayers, setSelectedPlayers] = useState<Player[]>([])
const [isSearching, setIsSearching] = useState(false)
// Search for players as user types
useEffect(() => {
const searchPlayers = async () => {
if (searchQuery.length < 2) {
setSearchResults([])
return
}
setIsSearching(true)
try {
const response = await fetch(`/api/players/search?q=${encodeURIComponent(searchQuery)}`)
const data = await response.json()
if (response.ok) {
// Filter out already selected players
const availablePlayers = data.players.filter(
(p: Player) => !selectedPlayers.find(sp => sp.id === p.id)
)
setSearchResults(availablePlayers)
}
} catch (err) {
console.error("Search failed:", err)
} finally {
setIsSearching(false)
}
}
const debounceTimer = setTimeout(searchPlayers, 300)
return () => clearTimeout(debounceTimer)
}, [searchQuery, selectedPlayers])
const addPlayer = (player: Player) => {
setSelectedPlayers([...selectedPlayers, player])
setSearchQuery("")
setSearchResults([])
}
const removePlayer = (playerId: number) => {
setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId))
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFormData({
@@ -23,13 +86,34 @@ export default function NewTournamentPage() {
})
}
const nextStep = () => {
if (step === 1) {
if (!formData.name.trim()) {
setError("Tournament name is required")
return
}
if (selectedPlayers.length < 2) {
setError("At least 2 participants are required")
return
}
}
setError("")
setStep(step + 1)
}
const prevStep = () => {
setStep(step - 1)
setError("")
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsLoading(true)
try {
const response = await fetch("/api/tournaments", {
// First create the tournament
const tournamentResponse = await fetch("/api/tournaments", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -43,13 +127,29 @@ export default function NewTournamentPage() {
}),
})
const data = await response.json()
const tournamentData = await tournamentResponse.json()
if (!response.ok) {
throw new Error(data.error || "Failed to create tournament")
if (!tournamentResponse.ok) {
throw new Error(tournamentData.error || "Failed to create tournament")
}
router.push(`/admin/tournaments/${data.tournament.id}`)
const tournamentId = tournamentData.tournament.id
// Add participants to the tournament
if (selectedPlayers.length > 0) {
await fetch(`/api/tournaments/${tournamentId}/participants`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
playerIds: selectedPlayers.map(p => p.id),
}),
})
}
// Redirect to game entry page
router.push(`/admin/tournaments/${tournamentId}/entry`)
} catch (err: any) {
setError(err.message)
} finally {
@@ -61,12 +161,29 @@ export default function NewTournamentPage() {
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-3xl mx-auto py-6 sm:px-6 lg:px-8">
<main className="max-w-4xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<h1 className="text-3xl font-bold text-gray-900 mb-6">
Create New Tournament
</h1>
{/* Step Indicator */}
<div className="mb-8">
<div className="flex items-center">
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${step >= 1 ? 'bg-green-600 text-white' : 'bg-gray-300 text-gray-600'}`}>
1
</div>
<div className={`flex-1 h-1 ${step >= 2 ? 'bg-green-600' : 'bg-gray-300'}`} />
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${step >= 2 ? 'bg-green-600 text-white' : 'bg-gray-300 text-gray-600'}`}>
2
</div>
</div>
<div className="flex justify-between mt-2 text-sm text-gray-600">
<span>Tournament Details</span>
<span>Participants</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4">
@@ -74,101 +191,198 @@ export default function NewTournamentPage() {
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Tournament Name *
</label>
<input
type="text"
name="name"
id="name"
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.name}
onChange={handleChange}
/>
</div>
{/* Step 1: Tournament Details */}
{step === 1 && (
<>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Tournament Name *
</label>
<input
type="text"
name="name"
id="name"
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
name="description"
id="description"
rows={3}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.description}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
Description
</label>
<textarea
name="description"
id="description"
rows={3}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.description}
onChange={handleChange}
/>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
Event Date
</label>
<input
type="date"
name="eventDate"
id="eventDate"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.eventDate}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="format" className="block text-sm font-medium text-gray-700">
Format *
</label>
<select
name="format"
id="format"
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.format}
onChange={handleChange}
>
<option value="round_robin">Round Robin</option>
<option value="single_elimination">Single Elimination</option>
<option value="double_elimination">Double Elimination</option>
<option value="swiss">Swiss</option>
</select>
</div>
</div>
<div>
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700">
Max Participants
</label>
<input
type="number"
name="maxParticipants"
id="maxParticipants"
min="2"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.maxParticipants}
onChange={handleChange}
/>
</div>
</>
)}
{/* Step 2: Participants */}
{step === 2 && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add Participants
</label>
<div className="relative">
<input
type="text"
placeholder="Search for players..."
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{isSearching && (
<div className="absolute right-3 top-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
</div>
)}
</div>
{searchResults.length > 0 && (
<div className="mt-2 border border-gray-300 rounded-md shadow-sm max-h-48 overflow-y-auto">
{searchResults.map(player => (
<div
key={player.id}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center"
onClick={() => addPlayer(player)}
>
<span>{player.name}</span>
<span className="text-sm text-gray-500">Elo: {player.currentElo}</span>
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Selected Participants ({selectedPlayers.length})
</label>
{selectedPlayers.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{selectedPlayers.map(player => (
<div
key={player.id}
className="bg-green-50 border border-green-200 rounded-md px-3 py-2 flex justify-between items-center"
>
<span className="text-sm">{player.name}</span>
<button
type="button"
onClick={() => removePlayer(player.id)}
className="text-red-600 hover:text-red-800 ml-2"
>
×
</button>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No participants added yet</p>
)}
</div>
</>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-4">
<div>
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
Event Date
</label>
<input
type="date"
name="eventDate"
id="eventDate"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.eventDate}
onChange={handleChange}
/>
{step > 1 && (
<button
type="button"
onClick={prevStep}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Back
</button>
)}
</div>
<div>
<label htmlFor="format" className="block text-sm font-medium text-gray-700">
Format *
</label>
<select
name="format"
id="format"
required
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.format}
onChange={handleChange}
>
<option value="round_robin">Round Robin</option>
<option value="single_elimination">Single Elimination</option>
<option value="double_elimination">Double Elimination</option>
<option value="swiss">Swiss</option>
</select>
<div className="space-x-3">
{step < 2 ? (
<button
type="button"
onClick={nextStep}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Next
</button>
) : (
<>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create Tournament & Enter Games"}
</button>
</>
)}
</div>
</div>
<div>
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700">
Max Participants
</label>
<input
type="number"
name="maxParticipants"
id="maxParticipants"
min="2"
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
value={formData.maxParticipants}
onChange={handleChange}
/>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create Tournament"}
</button>
</div>
</form>
</div>
</main>