Initial commit - Prode Mundial 2026

This commit is contained in:
2026-06-11 11:22:46 -03:00
commit 22c6f93e78
45 changed files with 2112 additions and 0 deletions
+16
View File
@@ -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>
)
}
+83
View File
@@ -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>
)
}
+97
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}
+108
View File
@@ -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>
)
}
+16
View File
@@ -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>
)
}
+151
View File
@@ -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 })
}
+22
View File
@@ -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.' })
}
+38
View File
@@ -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 })
}
}
+7
View File
@@ -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
}
+41
View File
@@ -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 })
}
}
+36
View File
@@ -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)
}
+50
View File
@@ -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)
}
+31
View File
@@ -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 })
}
+26
View File
@@ -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);
}
}
+26
View File
@@ -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>
)
}
+8
View File
@@ -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')
}
+25
View File
@@ -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 }}
/>
)
}
+88
View File
@@ -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>
)
}
+52
View File
@@ -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>
)
}
+31
View File
@@ -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>
)
}
+72
View File
@@ -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>
)
}
+43
View File
@@ -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 }
+9
View File
@@ -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
+25
View File
@@ -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()
}
+63
View File
@@ -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',
]