Files
euchre_camp/src/app/admin/tournaments/new/page.tsx
T
david 37eb1f8e21 fix: check response.ok before parsing JSON in fetch calls
Fix JSON parsing errors when server returns non-JSON responses:
- Check response.ok before calling response.json()
- Add fallback error messages using status text
- Apply fix to all fetch calls across 12 components

This prevents 'JSON.parse: unexpected character' errors when
server returns HTML error pages or other non-JSON responses.
2026-04-03 19:27:10 -07:00

664 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useEffect, useMemo } from "react"
import { useRouter } from "next/navigation"
import Navigation from "@/components/Navigation"
import { expectedRounds, expectedMatchups } from "@/lib/schedule-generator"
interface Player {
id: number
name: string
currentElo: number
}
interface TournamentFormData {
name: string
description: string
eventDate: string
format: string
tournamentType: 'individual' | 'team'
participants: number[]
}
type PairingMethod = 'elo' | 'manual' | 'random'
export default function NewTournamentPage() {
const router = useRouter()
const [step, setStep] = useState(1)
const [formData, setFormData] = useState<TournamentFormData>({
name: "",
description: "",
eventDate: "",
format: "round_robin",
tournamentType: "individual",
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)
// Sorting state
const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'currentElo'; direction: 'asc' | 'desc' }>({
key: 'name',
direction: 'asc'
})
// Team pairing state
const [pairingMethod, setPairingMethod] = useState<PairingMethod>('elo')
// Dynamic round preview calculation
const scheduleInfo = useMemo(() => {
if (formData.tournamentType === 'team') {
const teamCount = Math.floor(selectedPlayers.length / 2)
if (teamCount < 2) return null
return {
rounds: expectedRounds(teamCount),
matchups: expectedMatchups(teamCount),
teams: teamCount,
playerCount: selectedPlayers.length,
}
} else {
// Individual: players form teams of 2 for Euchre
const teamCount = Math.floor(selectedPlayers.length / 2)
if (teamCount < 2) return null
return {
rounds: expectedRounds(teamCount),
matchups: expectedMatchups(teamCount),
teams: teamCount,
playerCount: selectedPlayers.length,
}
}
}, [selectedPlayers.length, formData.tournamentType])
// Minimum players by format
const getMinPlayers = () => {
if (formData.tournamentType === 'team') {
return 4 // At least 2 teams
}
// Individual tournaments still need pairs for Euchre
return 4 // At least 2 teams of 2
}
// 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)}`)
if (response.ok) {
const data = await response.json()
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 handleSort = (key: 'name' | 'currentElo') => {
setSortConfig(prevConfig => ({
key,
direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc'
}))
}
const getSortedPlayers = () => {
const sorted = [...selectedPlayers].sort((a, b) => {
if (sortConfig.key === 'name') {
return sortConfig.direction === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
} else {
return sortConfig.direction === 'asc'
? a.currentElo - b.currentElo
: b.currentElo - a.currentElo
}
})
return sorted
}
// Generate team pairings based on method
const getTeamPairings = () => {
const sorted = [...selectedPlayers]
if (pairingMethod === 'elo') {
sorted.sort((a, b) => b.currentElo - a.currentElo)
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
} else if (pairingMethod === 'random') {
// Shuffle using Fisher-Yates
for (let i = sorted.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[sorted[i], sorted[j]] = [sorted[j], sorted[i]]
}
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
} else {
// Manual: just use current order
const teams: { player1: Player; player2: Player }[] = []
for (let i = 0; i < sorted.length - 1; i += 2) {
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
}
return teams
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
})
}
const nextStep = () => {
if (step === 1) {
if (!formData.name.trim()) {
setError("Tournament name is 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 minPlayers = getMinPlayers()
if (selectedPlayers.length < minPlayers) {
setError(`At least ${minPlayers} participants are required (${minPlayers / 2} teams)`)
setIsLoading(false)
return
}
if (selectedPlayers.length % 2 !== 0) {
setError("An even number of participants is required to form teams")
setIsLoading(false)
return
}
// Create the tournament
const tournamentResponse = await fetch("/api/tournaments", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: formData.name,
description: formData.description,
eventDate: formData.eventDate || null,
format: formData.format,
tournamentType: formData.tournamentType,
}),
})
const tournamentData = await tournamentResponse.json()
if (!tournamentResponse.ok) {
throw new Error(tournamentData.error || "Failed to create tournament")
}
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),
}),
})
}
// Auto-generate schedule for round_robin
if (formData.format === 'round_robin') {
await fetch(`/api/tournaments/${tournamentId}/schedule`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
})
}
// Redirect to schedule view
router.push(`/admin/tournaments/${tournamentId}/schedule`)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "An unexpected error occurred";
setError(message)
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<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">
<div className="text-sm text-red-700">{error}</div>
</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 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="tournamentType" className="block text-sm font-medium text-gray-700">
Tournament Type *
</label>
<select
name="tournamentType"
id="tournamentType"
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.tournamentType}
onChange={handleChange}
>
<option value="individual">Individual (players compete as individuals)</option>
<option value="team">Team (players compete in pairs/teams)</option>
</select>
<p className="mt-1 text-sm text-gray-500">
{formData.tournamentType === 'individual'
? 'Players register individually and compete on their own.'
: 'Players are paired into teams of two for competition.'}
</p>
</div>
</>
)}
{/* Step 2: Participants */}
{step === 2 && (
<>
{/* Round Preview */}
{scheduleInfo && (
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h3 className="text-sm font-medium text-green-800 mb-2">Tournament Preview</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-green-600 font-medium">{scheduleInfo.teams}</span>
<span className="text-green-700"> teams</span>
</div>
<div>
<span className="text-green-600 font-medium">{scheduleInfo.rounds}</span>
<span className="text-green-700"> rounds</span>
</div>
<div>
<span className="text-green-600 font-medium">{scheduleInfo.matchups}</span>
<span className="text-green-700"> matchups</span>
</div>
</div>
<p className="text-xs text-green-600 mt-2">
{formData.tournamentType === 'team'
? 'Players will be paired into teams below.'
: 'Players will be paired into teams of 2 for each round.'}
</p>
</div>
)}
{/* Minimum Players Warning */}
{selectedPlayers.length > 0 && selectedPlayers.length < getMinPlayers() && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<p className="text-sm text-yellow-700">
Need at least {getMinPlayers()} players ({getMinPlayers() / 2} teams).
Currently {selectedPlayers.length} player{selectedPlayers.length !== 1 ? 's' : ''}.
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Players
</label>
<div className="relative">
<input
type="text"
placeholder="Type a name to search..."
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>
{/* Team Pairing Options (only for team tournaments) */}
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Pairing Method
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="elo"
checked={pairingMethod === 'elo'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">By ELO (best + worst)</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="manual"
checked={pairingMethod === 'manual'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">Manual order</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="pairingMethod"
value="random"
checked={pairingMethod === 'random'}
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
className="mr-2"
/>
<span className="text-sm">Random</span>
</label>
</div>
</div>
)}
{/* Selected Players */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{formData.tournamentType === 'team'
? `Selected Players (${selectedPlayers.length})`
: `Selected Participants (${selectedPlayers.length})`}
</label>
{selectedPlayers.length > 0 ? (
<div className="border border-gray-300 rounded-md overflow-hidden">
{/* Column Headers */}
<div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
<div
className="col-span-6 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
onClick={() => handleSort('name')}
>
Name
{sortConfig.key === 'name' && (
<span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
</div>
<div
className="col-span-4 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
onClick={() => handleSort('currentElo')}
>
ELO
{sortConfig.key === 'currentElo' && (
<span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
)}
</div>
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">
Actions
</div>
</div>
{/* Player Rows */}
<div className="max-h-48 overflow-y-auto">
{getSortedPlayers().map(player => (
<div
key={player.id}
className="grid grid-cols-12 bg-green-50 border-b border-green-100 last:border-b-0"
>
<div className="col-span-6 px-3 py-2 text-sm truncate">
{player.name}
</div>
<div className="col-span-4 px-3 py-2 text-sm">
{player.currentElo}
</div>
<div className="col-span-2 px-3 py-2 flex justify-center">
<button
type="button"
onClick={() => removePlayer(player.id)}
className="text-red-600 hover:text-red-800"
>
×
</button>
</div>
</div>
))}
</div>
</div>
) : (
<p className="text-gray-500 text-sm">No participants added yet</p>
)}
</div>
{/* Team Preview (for team tournaments) */}
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Pairings Preview ({getTeamPairings().length} teams)
</label>
<div className="border border-gray-300 rounded-md overflow-hidden">
<div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">Team</div>
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 1</div>
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 2</div>
</div>
<div className="max-h-48 overflow-y-auto">
{getTeamPairings().map((team, index) => (
<div key={index} className="grid grid-cols-12 bg-blue-50 border-b border-blue-100 last:border-b-0">
<div className="col-span-2 px-3 py-2 text-sm font-medium text-blue-700">
Team {index + 1}
</div>
<div className="col-span-5 px-3 py-2 text-sm">
{team.player1.name} <span className="text-gray-400">({team.player1.currentElo})</span>
</div>
<div className="col-span-5 px-3 py-2 text-sm">
{team.player2.name} <span className="text-gray-400">({team.player2.currentElo})</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
</>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-4">
<div>
{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 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 || selectedPlayers.length < getMinPlayers()}
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 disabled:cursor-not-allowed"
>
{isLoading ? "Creating..." : "Create Tournament & Generate Schedule"}
</button>
</>
)}
</div>
</div>
</form>
</div>
</main>
</div>
)
}