Initial commit - Prode Mundial 2026
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { Nav } from '@/components/Nav'
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getSession()
|
||||
if (!session) redirect('/login')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{children}
|
||||
<div className="h-20" /> {/* spacer for fixed nav */}
|
||||
<Nav isAdmin={session.role === 'ADMIN'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Phase, LeaderboardEntry } from '@/types'
|
||||
import { PHASE_LABELS, PHASE_ORDER } from '@/types'
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const [phase, setPhase] = useState<Phase | 'all'>('all')
|
||||
const [data, setData] = useState<LeaderboardEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const url = phase === 'all' ? '/api/leaderboard' : `/api/leaderboard?phase=${phase}`
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(d => { setData(d); setLoading(false) })
|
||||
}, [phase])
|
||||
|
||||
const medals = ['🥇', '🥈', '🥉']
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-green-700 text-white px-4 pt-12 pb-4 sticky top-0 z-10">
|
||||
<h1 className="text-xl font-black mb-3">🏆 Ranking</h1>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
<button
|
||||
onClick={() => setPhase('all')}
|
||||
className={`whitespace-nowrap px-3 py-1.5 rounded-full text-xs font-semibold transition-all
|
||||
${phase === 'all' ? 'bg-white text-green-700' : 'bg-green-600 text-white border border-green-400'}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
{PHASE_ORDER.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPhase(p)}
|
||||
className={`whitespace-nowrap px-3 py-1.5 rounded-full text-xs font-semibold transition-all
|
||||
${phase === p ? 'bg-white text-green-700' : 'bg-green-600 text-white border border-green-400'}`}
|
||||
>
|
||||
{PHASE_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-green-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-16">Aún no hay puntos registrados</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.map((entry, i) => (
|
||||
<div
|
||||
key={entry.userId}
|
||||
className={`flex items-center bg-white rounded-2xl px-4 py-3 shadow-sm
|
||||
${i < 3 ? 'border-2 border-yellow-200' : 'border border-gray-100'}`}
|
||||
>
|
||||
<span className="w-8 text-xl text-center">
|
||||
{medals[i] ?? <span className="text-sm font-bold text-gray-400">{entry.rank}</span>}
|
||||
</span>
|
||||
<div className="flex-1 ml-3">
|
||||
<p className="font-bold text-gray-900 text-sm">{entry.name}</p>
|
||||
<p className="text-xs text-gray-500">{entry.count} pronósticos</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-xl font-black ${entry.total >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{entry.total >= 0 ? '+' : ''}{entry.total}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">puntos</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { MatchCard } from '@/components/MatchCard'
|
||||
import { PhaseFilter } from '@/components/PhaseFilter'
|
||||
import type { MatchData, Phase, PredictionResult } from '@/types'
|
||||
import { PHASE_LABELS } from '@/types'
|
||||
|
||||
export default function PredictionsPage() {
|
||||
const [phase, setPhase] = useState<Phase>('GROUP_STAGE')
|
||||
const [matches, setMatches] = useState<MatchData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
|
||||
const fetchMatches = useCallback(async (p: Phase) => {
|
||||
setLoading(true)
|
||||
const res = await fetch(`/api/matches?phase=${p}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setMatches(data)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchMatches(phase) }, [phase, fetchMatches])
|
||||
|
||||
const handlePredict = async (matchId: string, result: PredictionResult) => {
|
||||
setSavingId(matchId)
|
||||
const res = await fetch('/api/predictions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ matchId, result }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setMatches(prev =>
|
||||
prev.map(m => m.id === matchId ? { ...m, userPrediction: result } : m)
|
||||
)
|
||||
}
|
||||
setSavingId(null)
|
||||
}
|
||||
|
||||
const groups = phase === 'GROUP_STAGE'
|
||||
? [...new Set(matches.map(m => m.group).filter(Boolean))] as string[]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-green-700 text-white px-4 pt-12 pb-4 sticky top-0 z-10">
|
||||
<h1 className="text-xl font-black mb-3">⚽ Prode Mundial 2026</h1>
|
||||
<PhaseFilter selected={phase} onChange={setPhase} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-green-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : matches.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-16">No hay partidos disponibles</p>
|
||||
) : groups ? (
|
||||
groups.map(g => (
|
||||
<div key={g} className="mb-6">
|
||||
<h2 className="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Grupo {g}</span>
|
||||
</h2>
|
||||
{matches
|
||||
.filter(m => m.group === g)
|
||||
.map(m => (
|
||||
<MatchCard
|
||||
key={m.id}
|
||||
match={m}
|
||||
onPredict={handlePredict}
|
||||
saving={savingId === m.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="mb-2">
|
||||
<h2 className="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">
|
||||
{PHASE_LABELS[phase]}
|
||||
</h2>
|
||||
{matches.map(m => (
|
||||
<MatchCard
|
||||
key={m.id}
|
||||
match={m}
|
||||
onPredict={handlePredict}
|
||||
saving={savingId === m.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? 'Error al iniciar sesión')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/predictions')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-green-700 to-green-900 flex flex-col items-center justify-center p-6">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-3">⚽</div>
|
||||
<h1 className="text-3xl font-black text-white">Prode Mundial</h1>
|
||||
<p className="text-green-200 text-sm mt-1">FIFA World Cup 2026</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="w-full max-w-sm bg-white rounded-3xl shadow-2xl p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-5">Iniciar sesión</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="tu@email.com"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm bg-red-50 rounded-xl px-3 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-xl transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Ingresando...' : 'Ingresar'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
¿No tenés cuenta?{' '}
|
||||
<Link href="/register" className="text-green-600 font-semibold">
|
||||
Registrate
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? 'Error al registrarse')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/predictions')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-green-700 to-green-900 flex flex-col items-center justify-center p-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-3">⚽</div>
|
||||
<h1 className="text-3xl font-black text-white">Prode Mundial</h1>
|
||||
<p className="text-green-200 text-sm mt-1">FIFA World Cup 2026</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm bg-white rounded-3xl shadow-2xl p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-5">Crear cuenta</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Tu nombre"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="tu@email.com"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm bg-red-50 rounded-xl px-3 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-xl transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Creando cuenta...' : 'Registrarme'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-4">
|
||||
¿Ya tenés cuenta?{' '}
|
||||
<Link href="/login" className="text-green-600 font-semibold">
|
||||
Iniciar sesión
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { Nav } from '@/components/Nav'
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'ADMIN') redirect('/predictions')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{children}
|
||||
<div className="h-20" />
|
||||
<Nav isAdmin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Flag } from '@/components/Flag'
|
||||
import type { MatchData, Phase } from '@/types'
|
||||
import { PHASE_LABELS, PHASE_ORDER } from '@/types'
|
||||
|
||||
export default function AdminPage() {
|
||||
const [phase, setPhase] = useState<Phase>('GROUP_STAGE')
|
||||
const [matches, setMatches] = useState<MatchData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scores, setScores] = useState<Record<string, { home: string; away: string }>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch(`/api/matches?phase=${phase}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
setMatches(d)
|
||||
const initial: Record<string, { home: string; away: string }> = {}
|
||||
d.forEach((m: MatchData) => {
|
||||
initial[m.id] = {
|
||||
home: m.homeScore !== null ? String(m.homeScore) : '',
|
||||
away: m.awayScore !== null ? String(m.awayScore) : '',
|
||||
}
|
||||
})
|
||||
setScores(initial)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [phase])
|
||||
|
||||
const saveResult = async (matchId: string) => {
|
||||
const s = scores[matchId]
|
||||
if (s.home === '' || s.away === '') return
|
||||
setSaving(matchId)
|
||||
await fetch(`/api/admin/matches/${matchId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ homeScore: Number(s.home), awayScore: Number(s.away), status: 'FINISHED' }),
|
||||
})
|
||||
setMatches(prev => prev.map(m =>
|
||||
m.id === matchId
|
||||
? { ...m, homeScore: Number(s.home), awayScore: Number(s.away), status: 'FINISHED' }
|
||||
: m
|
||||
))
|
||||
setSaving(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="bg-gray-900 text-white px-4 pt-12 pb-4 sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-xl font-black">⚙️ Admin</h1>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSyncing(true)
|
||||
await fetch(`/api/admin/sync?secret=${process.env.NEXT_PUBLIC_SYNC_SECRET ?? ''}`, { method: 'POST' })
|
||||
setSyncing(false)
|
||||
}}
|
||||
className="bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-full disabled:opacity-60"
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? 'Sincronizando...' : '🔄 Sync externo'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{PHASE_ORDER.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPhase(p)}
|
||||
className={`whitespace-nowrap px-3 py-1.5 rounded-full text-xs font-semibold
|
||||
${phase === p ? 'bg-white text-gray-900' : 'bg-gray-700 text-white'}`}
|
||||
>
|
||||
{PHASE_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-4 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-8 h-8 border-4 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : matches.map(m => {
|
||||
const homeName = m.homeTeam?.shortName ?? m.homeLabel ?? '?'
|
||||
const awayName = m.awayTeam?.shortName ?? m.awayLabel ?? '?'
|
||||
const s = scores[m.id] ?? { home: '', away: '' }
|
||||
const date = new Date(m.scheduledAt).toLocaleDateString('es-AR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
return (
|
||||
<div key={m.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<div className="flex justify-between text-xs text-gray-400 mb-2">
|
||||
<span>#{m.matchNumber} · {date}</span>
|
||||
<span className={`font-semibold ${m.status === 'FINISHED' ? 'text-gray-500' : m.status === 'LIVE' ? 'text-red-500' : 'text-green-600'}`}>
|
||||
{m.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Home */}
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{m.homeTeam && <Flag isoCode={m.homeTeam.isoCode} size={24} />}
|
||||
<span className="text-sm font-bold">{homeName}</span>
|
||||
</div>
|
||||
|
||||
{/* Score inputs */}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
value={s.home}
|
||||
onChange={e => setScores(prev => ({ ...prev, [m.id]: { ...prev[m.id], home: e.target.value } }))}
|
||||
className="w-10 text-center border border-gray-300 rounded-lg py-1 font-bold text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
<span className="text-gray-400 font-bold">-</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
value={s.away}
|
||||
onChange={e => setScores(prev => ({ ...prev, [m.id]: { ...prev[m.id], away: e.target.value } }))}
|
||||
className="w-10 text-center border border-gray-300 rounded-lg py-1 font-bold text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Away */}
|
||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||
<span className="text-sm font-bold">{awayName}</span>
|
||||
{m.awayTeam && <Flag isoCode={m.awayTeam.isoCode} size={24} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => saveResult(m.id)}
|
||||
disabled={saving === m.id || s.home === '' || s.away === ''}
|
||||
className="w-full mt-3 bg-gray-900 text-white text-xs font-bold py-2 rounded-xl disabled:opacity-40 active:scale-98"
|
||||
>
|
||||
{saving === m.id ? 'Guardando...' : 'Guardar resultado'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { calculatePoints } from '@/lib/scoring'
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session || session.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'No autorizado' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const { homeScore, awayScore, status } = await req.json()
|
||||
|
||||
if (homeScore === undefined || awayScore === undefined) {
|
||||
return NextResponse.json({ error: 'Scores requeridos' }, { status: 400 })
|
||||
}
|
||||
|
||||
const match = await prisma.match.update({
|
||||
where: { id },
|
||||
data: {
|
||||
homeScore: Number(homeScore),
|
||||
awayScore: Number(awayScore),
|
||||
status: status ?? 'FINISHED',
|
||||
},
|
||||
})
|
||||
|
||||
// Calcular puntos para todas las predicciones de este partido
|
||||
if (match.status === 'FINISHED' && match.homeScore !== null && match.awayScore !== null) {
|
||||
const predictions = await prisma.prediction.findMany({ where: { matchId: id } })
|
||||
|
||||
await Promise.all(
|
||||
predictions.map(p =>
|
||||
prisma.prediction.update({
|
||||
where: { id: p.id },
|
||||
data: { points: calculatePoints(p.result as 'HOME' | 'DRAW' | 'AWAY', match.homeScore!, match.awayScore!) },
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, match })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
if (secret !== process.env.ADMIN_SYNC_SECRET) {
|
||||
return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const url = process.env.RESULTS_API_URL
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'RESULTS_API_URL no configurada' }, { status: 400 })
|
||||
}
|
||||
|
||||
// TODO: implementar cuando se provea la URL del sitio externo
|
||||
// const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.RESULTS_API_KEY}` } })
|
||||
// const data = await res.json()
|
||||
// ... procesar y actualizar partidos
|
||||
|
||||
return NextResponse.json({ ok: true, message: 'Endpoint listo. Configurar RESULTS_API_URL para activar sync.' })
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { signToken } from '@/lib/auth'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { email, password } = await req.json()
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: 'Email y contraseña requeridos' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Credenciales incorrectas' }, { status: 401 })
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password)
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: 'Credenciales incorrectas' }, { status: 401 })
|
||||
}
|
||||
|
||||
const token = await signToken({ id: user.id, email: user.email, name: user.name, role: user.role })
|
||||
|
||||
const response = NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } })
|
||||
response.cookies.set('prode_token', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
path: '/',
|
||||
})
|
||||
return response
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return NextResponse.json({ error: 'Error interno' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true })
|
||||
response.cookies.set('prode_token', '', { maxAge: 0, path: '/' })
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { signToken } from '@/lib/auth'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { email, password, name } = await req.json()
|
||||
|
||||
if (!email || !password || !name) {
|
||||
return NextResponse.json({ error: 'Todos los campos son requeridos' }, { status: 400 })
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return NextResponse.json({ error: 'La contraseña debe tener al menos 6 caracteres' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } })
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Ya existe una cuenta con ese email' }, { status: 409 })
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12)
|
||||
const user = await prisma.user.create({
|
||||
data: { email, password: hashed, name, role: 'PLAYER' },
|
||||
})
|
||||
|
||||
const token = await signToken({ id: user.id, email: user.email, name: user.name, role: user.role })
|
||||
|
||||
const response = NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } })
|
||||
response.cookies.set('prode_token', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
path: '/',
|
||||
})
|
||||
return response
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return NextResponse.json({ error: 'Error interno' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { Phase } from '@/types'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const phase = searchParams.get('phase') as Phase | null
|
||||
|
||||
const predictions = await prisma.prediction.findMany({
|
||||
where: {
|
||||
points: { not: null },
|
||||
...(phase ? { match: { phase } } : {}),
|
||||
},
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
})
|
||||
|
||||
const map = new Map<string, { name: string; email: string; total: number; count: number }>()
|
||||
|
||||
for (const p of predictions) {
|
||||
const existing = map.get(p.userId) ?? { name: p.user.name, email: p.user.email, total: 0, count: 0 }
|
||||
existing.total += p.points ?? 0
|
||||
existing.count += 1
|
||||
map.set(p.userId, existing)
|
||||
}
|
||||
|
||||
const sorted = [...map.entries()]
|
||||
.map(([userId, data]) => ({ userId, ...data }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map((entry, i) => ({ ...entry, rank: i + 1 }))
|
||||
|
||||
return NextResponse.json(sorted)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { Phase } from '@/types'
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const phase = searchParams.get('phase') as Phase | null
|
||||
const group = searchParams.get('group')
|
||||
|
||||
const matches = await prisma.match.findMany({
|
||||
where: {
|
||||
...(phase ? { phase } : {}),
|
||||
...(group ? { group } : {}),
|
||||
},
|
||||
include: {
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
predictions: {
|
||||
where: { userId: session.id },
|
||||
select: { result: true, points: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ scheduledAt: 'asc' }, { matchNumber: 'asc' }],
|
||||
})
|
||||
|
||||
const result = matches.map(m => ({
|
||||
id: m.id,
|
||||
matchNumber: m.matchNumber,
|
||||
phase: m.phase,
|
||||
group: m.group,
|
||||
matchday: m.matchday,
|
||||
homeTeam: m.homeTeam,
|
||||
awayTeam: m.awayTeam,
|
||||
homeLabel: m.homeLabel,
|
||||
awayLabel: m.awayLabel,
|
||||
scheduledAt: m.scheduledAt.toISOString(),
|
||||
venue: m.venue,
|
||||
homeScore: m.homeScore,
|
||||
awayScore: m.awayScore,
|
||||
status: m.status,
|
||||
userPrediction: m.predictions[0]?.result ?? null,
|
||||
userPoints: m.predictions[0]?.points ?? null,
|
||||
}))
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isMatchLocked } from '@/lib/scoring'
|
||||
import type { PredictionResult } from '@/types'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getSession()
|
||||
if (!session) return NextResponse.json({ error: 'No autorizado' }, { status: 401 })
|
||||
|
||||
const { matchId, result } = await req.json() as { matchId: string; result: PredictionResult }
|
||||
|
||||
if (!matchId || !['HOME', 'DRAW', 'AWAY'].includes(result)) {
|
||||
return NextResponse.json({ error: 'Datos inválidos' }, { status: 400 })
|
||||
}
|
||||
|
||||
const match = await prisma.match.findUnique({ where: { id: matchId } })
|
||||
if (!match) return NextResponse.json({ error: 'Partido no encontrado' }, { status: 404 })
|
||||
|
||||
if (isMatchLocked(match.scheduledAt) || match.status !== 'SCHEDULED') {
|
||||
return NextResponse.json({ error: 'Ya no se pueden modificar pronósticos para este partido' }, { status: 403 })
|
||||
}
|
||||
|
||||
const prediction = await prisma.prediction.upsert({
|
||||
where: { userId_matchId: { userId: session.id, matchId } },
|
||||
update: { result },
|
||||
create: { userId: session.id, matchId, result },
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, prediction })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.safe-area-pb {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Prode Mundial 2026',
|
||||
description: 'Pronosticá los partidos del Mundial 2026 con tu familia',
|
||||
manifest: '/manifest.json',
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
themeColor: '#16a34a',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
export default async function Home() {
|
||||
const session = await getSession()
|
||||
if (session) redirect('/predictions')
|
||||
redirect('/login')
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
interface FlagProps {
|
||||
isoCode: string
|
||||
name?: string
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Flag({ isoCode, name, size = 32, className = '' }: FlagProps) {
|
||||
const width = size * 1.5
|
||||
const height = size
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://flagcdn.com/w${width}/${isoCode}.png`}
|
||||
alt={name ? `Bandera de ${name}` : isoCode}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`object-cover rounded-sm ${className}`}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { Flag } from './Flag'
|
||||
import { PredictionButtons } from './PredictionButtons'
|
||||
import type { MatchData, PredictionResult } from '@/types'
|
||||
import { isMatchLocked } from '@/lib/scoring'
|
||||
|
||||
interface MatchCardProps {
|
||||
match: MatchData
|
||||
onPredict: (matchId: string, result: PredictionResult) => void
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
export function MatchCard({ match, onPredict, saving }: MatchCardProps) {
|
||||
const locked = isMatchLocked(match.scheduledAt) || match.status !== 'SCHEDULED'
|
||||
const finished = match.status === 'FINISHED'
|
||||
|
||||
const date = new Date(match.scheduledAt)
|
||||
const dateStr = date.toLocaleDateString('es-AR', { day: '2-digit', month: 'short' })
|
||||
const timeStr = date.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const homeName = match.homeTeam?.shortName ?? match.homeLabel ?? '?'
|
||||
const awayName = match.awayTeam?.shortName ?? match.awayLabel ?? '?'
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-2xl shadow-sm border border-gray-100 p-4 mb-3 ${locked ? 'opacity-90' : ''}`}>
|
||||
{/* Date + Status */}
|
||||
<div className="flex justify-between items-center mb-3 text-xs text-gray-500">
|
||||
<span>{dateStr} · {timeStr}</span>
|
||||
{match.status === 'LIVE' && (
|
||||
<span className="bg-red-500 text-white px-2 py-0.5 rounded-full font-semibold animate-pulse">EN VIVO</span>
|
||||
)}
|
||||
{finished && (
|
||||
<span className="bg-gray-200 text-gray-600 px-2 py-0.5 rounded-full font-semibold">FINALIZADO</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Teams */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Home */}
|
||||
<div className="flex flex-col items-center gap-1 flex-1">
|
||||
{match.homeTeam ? (
|
||||
<Flag isoCode={match.homeTeam.isoCode} name={match.homeTeam.name} size={36} />
|
||||
) : (
|
||||
<div className="w-14 h-9 bg-gray-100 rounded-sm flex items-center justify-center text-gray-400 text-xs">?</div>
|
||||
)}
|
||||
<span className="text-xs font-bold text-gray-800">{homeName}</span>
|
||||
</div>
|
||||
|
||||
{/* Score / VS */}
|
||||
<div className="flex flex-col items-center min-w-[52px]">
|
||||
{finished ? (
|
||||
<span className="text-2xl font-black text-gray-900">
|
||||
{match.homeScore} - {match.awayScore}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-gray-400">vs</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Away */}
|
||||
<div className="flex flex-col items-center gap-1 flex-1">
|
||||
{match.awayTeam ? (
|
||||
<Flag isoCode={match.awayTeam.isoCode} name={match.awayTeam.name} size={36} />
|
||||
) : (
|
||||
<div className="w-14 h-9 bg-gray-100 rounded-sm flex items-center justify-center text-gray-400 text-xs">?</div>
|
||||
)}
|
||||
<span className="text-xs font-bold text-gray-800">{awayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prediction */}
|
||||
<div className="mt-3">
|
||||
<PredictionButtons
|
||||
matchId={match.id}
|
||||
selected={match.userPrediction ?? null}
|
||||
locked={locked}
|
||||
points={match.userPoints ?? null}
|
||||
finished={finished}
|
||||
homeName={homeName}
|
||||
awayName={awayName}
|
||||
onSelect={(result) => onPredict(match.id, result)}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
interface NavProps {
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export function Nav({ isAdmin }: NavProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
const logout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const links = [
|
||||
{ href: '/predictions', label: 'Pronósticos', icon: '⚽' },
|
||||
{ href: '/leaderboard', label: 'Ranking', icon: '🏆' },
|
||||
...(isAdmin ? [{ href: '/admin', label: 'Admin', icon: '⚙️' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50 safe-area-pb">
|
||||
<div className="flex max-w-lg mx-auto">
|
||||
{links.map(link => {
|
||||
const active = pathname.startsWith(link.href)
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex-1 flex flex-col items-center py-3 text-xs font-medium transition-colors
|
||||
${active ? 'text-green-600' : 'text-gray-500'}`}
|
||||
>
|
||||
<span className="text-xl mb-0.5">{link.icon}</span>
|
||||
{link.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex-1 flex flex-col items-center py-3 text-xs font-medium text-gray-500"
|
||||
>
|
||||
<span className="text-xl mb-0.5">🚪</span>
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import type { Phase } from '@/types'
|
||||
import { PHASE_LABELS, PHASE_ORDER } from '@/types'
|
||||
|
||||
interface PhaseFilterProps {
|
||||
selected: Phase
|
||||
onChange: (phase: Phase) => void
|
||||
}
|
||||
|
||||
export function PhaseFilter({ selected, onChange }: PhaseFilterProps) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 px-4 scrollbar-hide">
|
||||
{PHASE_ORDER.map(phase => (
|
||||
<button
|
||||
key={phase}
|
||||
onClick={() => onChange(phase)}
|
||||
className={`
|
||||
whitespace-nowrap px-3 py-1.5 rounded-full text-xs font-semibold transition-all
|
||||
${selected === phase
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{PHASE_LABELS[phase]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import type { PredictionResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
matchId: string
|
||||
selected: PredictionResult | null
|
||||
locked: boolean
|
||||
finished: boolean
|
||||
points: number | null
|
||||
homeName: string
|
||||
awayName: string
|
||||
onSelect: (result: PredictionResult) => void
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const BUTTONS: { result: PredictionResult; label: (h: string, a: string) => string }[] = [
|
||||
{ result: 'HOME', label: (h) => h },
|
||||
{ result: 'DRAW', label: () => 'Empate' },
|
||||
{ result: 'AWAY', label: (_, a) => a },
|
||||
]
|
||||
|
||||
const COLORS: Record<PredictionResult, string> = {
|
||||
HOME: 'bg-green-600 text-white border-green-600',
|
||||
DRAW: 'bg-yellow-500 text-white border-yellow-500',
|
||||
AWAY: 'bg-blue-600 text-white border-blue-600',
|
||||
}
|
||||
|
||||
const IDLE = 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
|
||||
|
||||
export function PredictionButtons({ selected, locked, finished, points, homeName, awayName, onSelect, saving }: Props) {
|
||||
if (finished && selected) {
|
||||
const pts = points ?? 0
|
||||
const color = pts > 0 ? 'text-green-600' : pts < 0 ? 'text-red-500' : 'text-gray-500'
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">
|
||||
Tu pronóstico: <strong>{selected === 'HOME' ? homeName : selected === 'AWAY' ? awayName : 'Empate'}</strong>
|
||||
</span>
|
||||
<span className={`font-black text-base ${color}`}>
|
||||
{pts > 0 ? '+' : ''}{pts} pts
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (finished && !selected) {
|
||||
return <p className="text-xs text-gray-400 text-center">No pronosticaste este partido</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{BUTTONS.map(({ result, label }) => {
|
||||
const isSelected = selected === result
|
||||
return (
|
||||
<button
|
||||
key={result}
|
||||
disabled={locked || saving}
|
||||
onClick={() => onSelect(result)}
|
||||
className={`
|
||||
flex-1 py-2 px-1 rounded-xl border-2 text-xs font-bold transition-all
|
||||
${isSelected ? COLORS[result] : IDLE}
|
||||
${locked ? 'cursor-not-allowed opacity-60' : 'active:scale-95'}
|
||||
`}
|
||||
>
|
||||
{label(homeName, awayName)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { cookies } from 'next/headers'
|
||||
import type { JWTPayload } from '@/types'
|
||||
|
||||
const COOKIE_NAME = 'prode_token'
|
||||
const secret = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET ?? 'fallback_secret_change_in_production'
|
||||
)
|
||||
|
||||
export async function signToken(payload: JWTPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(secret)
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, secret)
|
||||
return payload as unknown as JWTPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<JWTPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value
|
||||
if (!token) return null
|
||||
return verifyToken(token)
|
||||
}
|
||||
|
||||
export function setSessionCookie(token: string) {
|
||||
const response = new Headers()
|
||||
response.append(
|
||||
'Set-Cookie',
|
||||
`${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
export { COOKIE_NAME }
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['error'] : [] })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PredictionResult } from '@/types'
|
||||
|
||||
export function calculatePoints(
|
||||
prediction: PredictionResult,
|
||||
homeScore: number,
|
||||
awayScore: number
|
||||
): number {
|
||||
const actual: PredictionResult =
|
||||
homeScore > awayScore ? 'HOME' : homeScore < awayScore ? 'AWAY' : 'DRAW'
|
||||
|
||||
if (prediction === actual) {
|
||||
return actual === 'DRAW' ? 1 : 3
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
export function getActualResult(homeScore: number, awayScore: number): PredictionResult {
|
||||
if (homeScore > awayScore) return 'HOME'
|
||||
if (homeScore < awayScore) return 'AWAY'
|
||||
return 'DRAW'
|
||||
}
|
||||
|
||||
export function isMatchLocked(scheduledAt: Date | string): boolean {
|
||||
return new Date(scheduledAt) <= new Date()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type Role = 'ADMIN' | 'PLAYER'
|
||||
export type Phase = 'GROUP_STAGE' | 'ROUND_OF_32' | 'ROUND_OF_16' | 'QUARTER_FINALS' | 'SEMI_FINALS' | 'THIRD_PLACE' | 'FINAL'
|
||||
export type MatchStatus = 'SCHEDULED' | 'LIVE' | 'FINISHED'
|
||||
export type PredictionResult = 'HOME' | 'DRAW' | 'AWAY'
|
||||
|
||||
export interface JWTPayload {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
export interface TeamData {
|
||||
id: string
|
||||
name: string
|
||||
shortName: string
|
||||
isoCode: string
|
||||
group: string
|
||||
confederation: string
|
||||
}
|
||||
|
||||
export interface MatchData {
|
||||
id: string
|
||||
matchNumber: number
|
||||
phase: Phase
|
||||
group: string | null
|
||||
matchday: number | null
|
||||
homeTeam: TeamData | null
|
||||
awayTeam: TeamData | null
|
||||
homeLabel: string | null
|
||||
awayLabel: string | null
|
||||
scheduledAt: string
|
||||
venue: string | null
|
||||
homeScore: number | null
|
||||
awayScore: number | null
|
||||
status: MatchStatus
|
||||
userPrediction?: PredictionResult | null
|
||||
userPoints?: number | null
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
userId: string
|
||||
name: string
|
||||
email: string
|
||||
totalPoints: number
|
||||
predictions: number
|
||||
rank: number
|
||||
}
|
||||
|
||||
export const PHASE_LABELS: Record<Phase, string> = {
|
||||
GROUP_STAGE: 'Fase de Grupos',
|
||||
ROUND_OF_32: 'Octavos de Final',
|
||||
ROUND_OF_16: 'Cuartos de Final',
|
||||
QUARTER_FINALS:'Cuartos',
|
||||
SEMI_FINALS: 'Semifinales',
|
||||
THIRD_PLACE: 'Tercer Puesto',
|
||||
FINAL: 'Final',
|
||||
}
|
||||
|
||||
export const PHASE_ORDER: Phase[] = [
|
||||
'GROUP_STAGE', 'ROUND_OF_32', 'ROUND_OF_16',
|
||||
'QUARTER_FINALS', 'SEMI_FINALS', 'THIRD_PLACE', 'FINAL',
|
||||
]
|
||||
Reference in New Issue
Block a user