Initial commit - Prode Mundial 2026
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# Base de datos
|
||||||
|
POSTGRES_USER=prode
|
||||||
|
POSTGRES_PASSWORD=prode_secret
|
||||||
|
POSTGRES_DB=prode_db
|
||||||
|
|
||||||
|
# JWT - CAMBIAR en producción
|
||||||
|
JWT_SECRET=un_secret_muy_largo_y_seguro_aqui
|
||||||
|
|
||||||
|
# URL del sitio externo de resultados (se configura cuando esté disponible)
|
||||||
|
RESULTS_API_URL=
|
||||||
|
RESULTS_API_KEY=
|
||||||
|
|
||||||
|
# Secret para proteger el endpoint de sync
|
||||||
|
ADMIN_SYNC_SECRET=sync_secret_cambialo
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Prisma generated
|
||||||
|
app/prisma/migrations/dev.db
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ">>> Ejecutando migraciones..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
echo ">>> Verificando seed..."
|
||||||
|
TEAM_COUNT=$(node -e "
|
||||||
|
const { PrismaClient } = require('./node_modules/@prisma/client');
|
||||||
|
const p = new PrismaClient();
|
||||||
|
p.team.count().then(c => { console.log(c); p.\$disconnect(); }).catch(() => { console.log(0); });
|
||||||
|
")
|
||||||
|
|
||||||
|
if [ "$TEAM_COUNT" = "0" ]; then
|
||||||
|
echo ">>> Cargando datos iniciales..."
|
||||||
|
node -e "
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
execSync('node prisma/seed.js', { stdio: 'inherit' });
|
||||||
|
" || echo "Seed via JS no disponible, salteando..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">>> Iniciando servidor..."
|
||||||
|
exec node server.js
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { jwtVerify } from 'jose'
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode(
|
||||||
|
process.env.JWT_SECRET ?? 'fallback_secret_change_in_production'
|
||||||
|
)
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login', '/register', '/api/auth/login', '/api/auth/register']
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
if (PUBLIC_PATHS.some(p => pathname.startsWith(p))) {
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = request.cookies.get('prode_token')?.value
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, secret)
|
||||||
|
|
||||||
|
if (pathname.startsWith('/admin') || pathname.startsWith('/api/admin')) {
|
||||||
|
if (payload.role !== 'ADMIN') {
|
||||||
|
return NextResponse.redirect(new URL('/predictions', request.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|.*\\.png|.*\\.jpg|.*\\.svg).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'flagcdn.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "prode-mundial-2026",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:reset": "prisma migrate reset --force",
|
||||||
|
"dev:migrate": "prisma migrate dev --schema ./prisma/schema.dev.prisma",
|
||||||
|
"dev:seed": "tsx prisma/seed.ts",
|
||||||
|
"dev:generate": "prisma generate --schema ./prisma/schema.dev.prisma"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.17.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jose": "^5.6.3",
|
||||||
|
"next": "^14.2.5",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.39",
|
||||||
|
"prisma": "^5.17.0",
|
||||||
|
"tailwindcss": "^3.4.6",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../node_modules/.prisma/client"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String
|
||||||
|
role String @default("PLAYER")
|
||||||
|
predictions Prediction[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
shortName String @unique
|
||||||
|
isoCode String @unique
|
||||||
|
group String
|
||||||
|
groupOrder Int
|
||||||
|
confederation String
|
||||||
|
homeMatches Match[] @relation("HomeTeam")
|
||||||
|
awayMatches Match[] @relation("AwayTeam")
|
||||||
|
|
||||||
|
@@index([group])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Match {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
matchNumber Int @unique
|
||||||
|
phase String
|
||||||
|
group String?
|
||||||
|
matchday Int?
|
||||||
|
homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||||
|
homeTeamId String?
|
||||||
|
awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id])
|
||||||
|
awayTeamId String?
|
||||||
|
homeLabel String?
|
||||||
|
awayLabel String?
|
||||||
|
scheduledAt DateTime
|
||||||
|
venue String?
|
||||||
|
homeScore Int?
|
||||||
|
awayScore Int?
|
||||||
|
status String @default("SCHEDULED")
|
||||||
|
predictions Prediction[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([phase])
|
||||||
|
@@index([group])
|
||||||
|
@@index([status])
|
||||||
|
@@index([scheduledAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Prediction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId String
|
||||||
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
|
matchId String
|
||||||
|
result String
|
||||||
|
points Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, matchId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([matchId])
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String
|
||||||
|
role Role @default(PLAYER)
|
||||||
|
predictions Prediction[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
PLAYER
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
shortName String @unique
|
||||||
|
isoCode String @unique
|
||||||
|
group String
|
||||||
|
groupOrder Int
|
||||||
|
confederation String
|
||||||
|
homeMatches Match[] @relation("HomeTeam")
|
||||||
|
awayMatches Match[] @relation("AwayTeam")
|
||||||
|
|
||||||
|
@@index([group])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Match {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
matchNumber Int @unique
|
||||||
|
phase Phase
|
||||||
|
group String?
|
||||||
|
matchday Int?
|
||||||
|
homeTeam Team? @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||||
|
homeTeamId String?
|
||||||
|
awayTeam Team? @relation("AwayTeam", fields: [awayTeamId], references: [id])
|
||||||
|
awayTeamId String?
|
||||||
|
homeLabel String?
|
||||||
|
awayLabel String?
|
||||||
|
scheduledAt DateTime
|
||||||
|
venue String?
|
||||||
|
homeScore Int?
|
||||||
|
awayScore Int?
|
||||||
|
status MatchStatus @default(SCHEDULED)
|
||||||
|
predictions Prediction[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([phase])
|
||||||
|
@@index([group])
|
||||||
|
@@index([status])
|
||||||
|
@@index([scheduledAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Phase {
|
||||||
|
GROUP_STAGE
|
||||||
|
ROUND_OF_32
|
||||||
|
ROUND_OF_16
|
||||||
|
QUARTER_FINALS
|
||||||
|
SEMI_FINALS
|
||||||
|
THIRD_PLACE
|
||||||
|
FINAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MatchStatus {
|
||||||
|
SCHEDULED
|
||||||
|
LIVE
|
||||||
|
FINISHED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Prediction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId String
|
||||||
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
|
matchId String
|
||||||
|
result PredictionResult
|
||||||
|
points Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, matchId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([matchId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PredictionResult {
|
||||||
|
HOME
|
||||||
|
DRAW
|
||||||
|
AWAY
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { PrismaClient, Phase, MatchStatus } from '@prisma/client'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const teams = [
|
||||||
|
// GROUP A
|
||||||
|
{ name: 'Mexico', shortName: 'MEX', isoCode: 'mx', group: 'A', groupOrder: 1, confederation: 'CONCACAF' },
|
||||||
|
{ name: 'South Africa', shortName: 'RSA', isoCode: 'za', group: 'A', groupOrder: 2, confederation: 'CAF' },
|
||||||
|
{ name: 'South Korea', shortName: 'KOR', isoCode: 'kr', group: 'A', groupOrder: 3, confederation: 'AFC' },
|
||||||
|
{ name: 'Czech Republic',shortName: 'CZE', isoCode: 'cz', group: 'A', groupOrder: 4, confederation: 'UEFA' },
|
||||||
|
// GROUP B
|
||||||
|
{ name: 'Canada', shortName: 'CAN', isoCode: 'ca', group: 'B', groupOrder: 1, confederation: 'CONCACAF' },
|
||||||
|
{ name: 'Bosnia and Herzegovina',shortName: 'BIH', isoCode: 'ba', group: 'B', groupOrder: 2, confederation: 'UEFA' },
|
||||||
|
{ name: 'Qatar', shortName: 'QAT', isoCode: 'qa', group: 'B', groupOrder: 3, confederation: 'AFC' },
|
||||||
|
{ name: 'Switzerland', shortName: 'SUI', isoCode: 'ch', group: 'B', groupOrder: 4, confederation: 'UEFA' },
|
||||||
|
// GROUP C
|
||||||
|
{ name: 'Brazil', shortName: 'BRA', isoCode: 'br', group: 'C', groupOrder: 1, confederation: 'CONMEBOL' },
|
||||||
|
{ name: 'Morocco', shortName: 'MAR', isoCode: 'ma', group: 'C', groupOrder: 2, confederation: 'CAF' },
|
||||||
|
{ name: 'Haiti', shortName: 'HAI', isoCode: 'ht', group: 'C', groupOrder: 3, confederation: 'CONCACAF' },
|
||||||
|
{ name: 'Scotland', shortName: 'SCO', isoCode: 'gb-sct', group: 'C', groupOrder: 4, confederation: 'UEFA' },
|
||||||
|
// GROUP D
|
||||||
|
{ name: 'United States', shortName: 'USA', isoCode: 'us', group: 'D', groupOrder: 1, confederation: 'CONCACAF' },
|
||||||
|
{ name: 'Paraguay', shortName: 'PAR', isoCode: 'py', group: 'D', groupOrder: 2, confederation: 'CONMEBOL' },
|
||||||
|
{ name: 'Australia', shortName: 'AUS', isoCode: 'au', group: 'D', groupOrder: 3, confederation: 'AFC' },
|
||||||
|
{ name: 'Turkey', shortName: 'TUR', isoCode: 'tr', group: 'D', groupOrder: 4, confederation: 'UEFA' },
|
||||||
|
// GROUP E
|
||||||
|
{ name: 'Germany', shortName: 'GER', isoCode: 'de', group: 'E', groupOrder: 1, confederation: 'UEFA' },
|
||||||
|
{ name: 'Curaçao', shortName: 'CUW', isoCode: 'cw', group: 'E', groupOrder: 2, confederation: 'CONCACAF' },
|
||||||
|
{ name: 'Ivory Coast', shortName: 'CIV', isoCode: 'ci', group: 'E', groupOrder: 3, confederation: 'CAF' },
|
||||||
|
{ name: 'Ecuador', shortName: 'ECU', isoCode: 'ec', group: 'E', groupOrder: 4, confederation: 'CONMEBOL' },
|
||||||
|
// GROUP F
|
||||||
|
{ name: 'Netherlands', shortName: 'NED', isoCode: 'nl', group: 'F', groupOrder: 1, confederation: 'UEFA' },
|
||||||
|
{ name: 'Japan', shortName: 'JPN', isoCode: 'jp', group: 'F', groupOrder: 2, confederation: 'AFC' },
|
||||||
|
{ name: 'Sweden', shortName: 'SWE', isoCode: 'se', group: 'F', groupOrder: 3, confederation: 'UEFA' },
|
||||||
|
{ name: 'Tunisia', shortName: 'TUN', isoCode: 'tn', group: 'F', groupOrder: 4, confederation: 'CAF' },
|
||||||
|
// GROUP G
|
||||||
|
{ name: 'Belgium', shortName: 'BEL', isoCode: 'be', group: 'G', groupOrder: 1, confederation: 'UEFA' },
|
||||||
|
{ name: 'Egypt', shortName: 'EGY', isoCode: 'eg', group: 'G', groupOrder: 2, confederation: 'CAF' },
|
||||||
|
{ name: 'Iran', shortName: 'IRN', isoCode: 'ir', group: 'G', groupOrder: 3, confederation: 'AFC' },
|
||||||
|
{ name: 'New Zealand', shortName: 'NZL', isoCode: 'nz', group: 'G', groupOrder: 4, confederation: 'OFC' },
|
||||||
|
// GROUP H
|
||||||
|
{ name: 'Spain', shortName: 'ESP', isoCode: 'es', group: 'H', groupOrder: 1, confederation: 'UEFA' },
|
||||||
|
{ name: 'Cape Verde', shortName: 'CPV', isoCode: 'cv', group: 'H', groupOrder: 2, confederation: 'CAF' },
|
||||||
|
{ name: 'Saudi Arabia', shortName: 'KSA', isoCode: 'sa', group: 'H', groupOrder: 3, confederation: 'AFC' },
|
||||||
|
{ name: 'Uruguay', shortName: 'URU', isoCode: 'uy', group: 'H', groupOrder: 4, confederation: 'CONMEBOL' },
|
||||||
|
// GROUP I
|
||||||
|
{ name: 'France', shortName: 'FRA', isoCode: 'fr', group: 'I', groupOrder: 1, confederation: 'UEFA' },
|
||||||
|
{ name: 'Senegal', shortName: 'SEN', isoCode: 'sn', group: 'I', groupOrder: 2, confederation: 'CAF' },
|
||||||
|
{ name: 'Iraq', shortName: 'IRQ', isoCode: 'iq', group: 'I', groupOrder: 3, confederation: 'AFC' },
|
||||||
|
{ name: 'Norway', shortName: 'NOR', isoCode: 'no', group: 'I', groupOrder: 4, confederation: 'UEFA' },
|
||||||
|
// GROUP J
|
||||||
|
{ name: 'Argentina', shortName: 'ARG', isoCode: 'ar', group: 'J', groupOrder: 1, confederation: 'CONMEBOL' },
|
||||||
|
{ name: 'Algeria', shortName: 'ALG', isoCode: 'dz', group: 'J', groupOrder: 2, confederation: 'CAF' },
|
||||||
|
{ name: 'Austria', shortName: 'AUT', isoCode: 'at', group: 'J', groupOrder: 3, confederation: 'UEFA' },
|
||||||
|
{ name: 'Jordan', shortName: 'JOR', isoCode: 'jo', group: 'J', groupOrder: 4, confederation: 'AFC' },
|
||||||
|
// GROUP K
|
||||||
|
{ name: 'Portugal', shortName: 'POR', isoCode: 'pt', group: 'K', groupOrder: 1, confederation: 'UEFA' },
|
||||||
|
{ name: 'DR Congo', shortName: 'COD', isoCode: 'cd', group: 'K', groupOrder: 2, confederation: 'CAF' },
|
||||||
|
{ name: 'Uzbekistan', shortName: 'UZB', isoCode: 'uz', group: 'K', groupOrder: 3, confederation: 'AFC' },
|
||||||
|
{ name: 'Colombia', shortName: 'COL', isoCode: 'co', group: 'K', groupOrder: 4, confederation: 'CONMEBOL' },
|
||||||
|
// GROUP L
|
||||||
|
{ name: 'England', shortName: 'ENG', isoCode: 'gb-eng', group: 'L', groupOrder: 1, confederation: 'UEFA' },
|
||||||
|
{ name: 'Croatia', shortName: 'CRO', isoCode: 'hr', group: 'L', groupOrder: 2, confederation: 'UEFA' },
|
||||||
|
{ name: 'Ghana', shortName: 'GHA', isoCode: 'gh', group: 'L', groupOrder: 3, confederation: 'CAF' },
|
||||||
|
{ name: 'Panama', shortName: 'PAN', isoCode: 'pa', group: 'L', groupOrder: 4, confederation: 'CONCACAF' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Fechas por grupo: cada grupo tiene 3 fechas (jornadas)
|
||||||
|
// Jornada 1: 11-16 junio | Jornada 2: 17-23 junio | Jornada 3: 25-30 junio
|
||||||
|
const groupDates: Record<string, { md1a: Date; md1b: Date; md2a: Date; md2b: Date; md3: Date }> = {
|
||||||
|
A: { md1a: new Date('2026-06-11T18:00:00Z'), md1b: new Date('2026-06-11T21:00:00Z'), md2a: new Date('2026-06-17T18:00:00Z'), md2b: new Date('2026-06-17T21:00:00Z'), md3: new Date('2026-06-25T21:00:00Z') },
|
||||||
|
B: { md1a: new Date('2026-06-12T18:00:00Z'), md1b: new Date('2026-06-12T21:00:00Z'), md2a: new Date('2026-06-18T18:00:00Z'), md2b: new Date('2026-06-18T21:00:00Z'), md3: new Date('2026-06-26T21:00:00Z') },
|
||||||
|
C: { md1a: new Date('2026-06-12T18:00:00Z'), md1b: new Date('2026-06-12T21:00:00Z'), md2a: new Date('2026-06-18T18:00:00Z'), md2b: new Date('2026-06-18T21:00:00Z'), md3: new Date('2026-06-26T18:00:00Z') },
|
||||||
|
D: { md1a: new Date('2026-06-13T18:00:00Z'), md1b: new Date('2026-06-13T21:00:00Z'), md2a: new Date('2026-06-19T18:00:00Z'), md2b: new Date('2026-06-19T21:00:00Z'), md3: new Date('2026-06-27T21:00:00Z') },
|
||||||
|
E: { md1a: new Date('2026-06-13T18:00:00Z'), md1b: new Date('2026-06-13T21:00:00Z'), md2a: new Date('2026-06-19T18:00:00Z'), md2b: new Date('2026-06-19T21:00:00Z'), md3: new Date('2026-06-27T18:00:00Z') },
|
||||||
|
F: { md1a: new Date('2026-06-14T18:00:00Z'), md1b: new Date('2026-06-14T21:00:00Z'), md2a: new Date('2026-06-20T18:00:00Z'), md2b: new Date('2026-06-20T21:00:00Z'), md3: new Date('2026-06-28T21:00:00Z') },
|
||||||
|
G: { md1a: new Date('2026-06-14T18:00:00Z'), md1b: new Date('2026-06-14T21:00:00Z'), md2a: new Date('2026-06-20T18:00:00Z'), md2b: new Date('2026-06-20T21:00:00Z'), md3: new Date('2026-06-28T18:00:00Z') },
|
||||||
|
H: { md1a: new Date('2026-06-15T18:00:00Z'), md1b: new Date('2026-06-15T21:00:00Z'), md2a: new Date('2026-06-21T18:00:00Z'), md2b: new Date('2026-06-21T21:00:00Z'), md3: new Date('2026-06-29T21:00:00Z') },
|
||||||
|
I: { md1a: new Date('2026-06-15T18:00:00Z'), md1b: new Date('2026-06-15T21:00:00Z'), md2a: new Date('2026-06-21T18:00:00Z'), md2b: new Date('2026-06-21T21:00:00Z'), md3: new Date('2026-06-29T18:00:00Z') },
|
||||||
|
J: { md1a: new Date('2026-06-16T18:00:00Z'), md1b: new Date('2026-06-16T21:00:00Z'), md2a: new Date('2026-06-22T18:00:00Z'), md2b: new Date('2026-06-22T21:00:00Z'), md3: new Date('2026-06-30T21:00:00Z') },
|
||||||
|
K: { md1a: new Date('2026-06-16T18:00:00Z'), md1b: new Date('2026-06-16T21:00:00Z'), md2a: new Date('2026-06-22T18:00:00Z'), md2b: new Date('2026-06-22T21:00:00Z'), md3: new Date('2026-06-30T18:00:00Z') },
|
||||||
|
L: { md1a: new Date('2026-06-17T18:00:00Z'), md1b: new Date('2026-06-17T21:00:00Z'), md2a: new Date('2026-06-23T18:00:00Z'), md2b: new Date('2026-06-23T21:00:00Z'), md3: new Date('2026-07-01T21:00:00Z') },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding teams...')
|
||||||
|
|
||||||
|
const teamMap: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
const t = await prisma.team.upsert({
|
||||||
|
where: { shortName: team.shortName },
|
||||||
|
update: team,
|
||||||
|
create: team,
|
||||||
|
})
|
||||||
|
teamMap[team.shortName] = t.id
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${teams.length} teams seeded.`)
|
||||||
|
|
||||||
|
// ── GROUP STAGE MATCHES ─────────────────────────────────────────────────────
|
||||||
|
console.log('Seeding group stage matches...')
|
||||||
|
|
||||||
|
const groups = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
|
||||||
|
|
||||||
|
for (let gi = 0; gi < groups.length; gi++) {
|
||||||
|
const g = groups[gi]
|
||||||
|
const baseMatchNum = gi * 6 + 1
|
||||||
|
const gTeams = teams.filter(t => t.group === g).sort((a, b) => a.groupOrder - b.groupOrder)
|
||||||
|
const [t1, t2, t3, t4] = gTeams
|
||||||
|
const d = groupDates[g]
|
||||||
|
|
||||||
|
const groupMatches = [
|
||||||
|
// Jornada 1
|
||||||
|
{ matchNumber: baseMatchNum, homeShort: t1.shortName, awayShort: t2.shortName, scheduledAt: d.md1a, matchday: 1 },
|
||||||
|
{ matchNumber: baseMatchNum + 1, homeShort: t3.shortName, awayShort: t4.shortName, scheduledAt: d.md1b, matchday: 1 },
|
||||||
|
// Jornada 2
|
||||||
|
{ matchNumber: baseMatchNum + 2, homeShort: t1.shortName, awayShort: t3.shortName, scheduledAt: d.md2a, matchday: 2 },
|
||||||
|
{ matchNumber: baseMatchNum + 3, homeShort: t2.shortName, awayShort: t4.shortName, scheduledAt: d.md2b, matchday: 2 },
|
||||||
|
// Jornada 3 (simultáneos)
|
||||||
|
{ matchNumber: baseMatchNum + 4, homeShort: t1.shortName, awayShort: t4.shortName, scheduledAt: d.md3, matchday: 3 },
|
||||||
|
{ matchNumber: baseMatchNum + 5, homeShort: t2.shortName, awayShort: t3.shortName, scheduledAt: d.md3, matchday: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const m of groupMatches) {
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: m.matchNumber },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: m.matchNumber,
|
||||||
|
phase: Phase.GROUP_STAGE,
|
||||||
|
group: g,
|
||||||
|
matchday: m.matchday,
|
||||||
|
homeTeamId: teamMap[m.homeShort],
|
||||||
|
awayTeamId: teamMap[m.awayShort],
|
||||||
|
scheduledAt: m.scheduledAt,
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ROUND OF 32 (16 partidos) ────────────────────────────────────────────────
|
||||||
|
console.log('Seeding knockout matches...')
|
||||||
|
|
||||||
|
const r32Labels = [
|
||||||
|
{ home: '1° Grupo A', away: '3° Grupo B/C/D' },
|
||||||
|
{ home: '1° Grupo C', away: '3° Grupo A/B/F' },
|
||||||
|
{ home: '1° Grupo B', away: '3° Grupo A/C/D' },
|
||||||
|
{ home: '1° Grupo D', away: '3° Grupo B/C/F' },
|
||||||
|
{ home: '1° Grupo E', away: '3° Grupo A/B/E' },
|
||||||
|
{ home: '1° Grupo G', away: '3° Grupo A/C/E' },
|
||||||
|
{ home: '1° Grupo F', away: '3° Grupo A/D/E' },
|
||||||
|
{ home: '1° Grupo H', away: '3° Grupo B/D/E' },
|
||||||
|
{ home: '1° Grupo I', away: '3° Grupo C/D/F' },
|
||||||
|
{ home: '1° Grupo K', away: '3° Grupo B/E/F' },
|
||||||
|
{ home: '1° Grupo J', away: '3° Grupo C/E/F' },
|
||||||
|
{ home: '1° Grupo L', away: '3° Grupo A/E/F' },
|
||||||
|
{ home: '2° Grupo A', away: '2° Grupo B' },
|
||||||
|
{ home: '2° Grupo C', away: '2° Grupo D' },
|
||||||
|
{ home: '2° Grupo E', away: '2° Grupo F' },
|
||||||
|
{ home: '2° Grupo G', away: '2° Grupo H' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const r32Dates = [
|
||||||
|
new Date('2026-07-04T18:00:00Z'), new Date('2026-07-04T21:00:00Z'),
|
||||||
|
new Date('2026-07-05T18:00:00Z'), new Date('2026-07-05T21:00:00Z'),
|
||||||
|
new Date('2026-07-06T18:00:00Z'), new Date('2026-07-06T21:00:00Z'),
|
||||||
|
new Date('2026-07-07T18:00:00Z'), new Date('2026-07-07T21:00:00Z'),
|
||||||
|
new Date('2026-07-08T18:00:00Z'), new Date('2026-07-08T21:00:00Z'),
|
||||||
|
new Date('2026-07-09T18:00:00Z'), new Date('2026-07-09T21:00:00Z'),
|
||||||
|
new Date('2026-07-10T18:00:00Z'), new Date('2026-07-10T21:00:00Z'),
|
||||||
|
new Date('2026-07-11T18:00:00Z'), new Date('2026-07-11T21:00:00Z'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: 73 + i },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: 73 + i,
|
||||||
|
phase: Phase.ROUND_OF_32,
|
||||||
|
homeLabel: r32Labels[i].home,
|
||||||
|
awayLabel: r32Labels[i].away,
|
||||||
|
scheduledAt: r32Dates[i],
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ROUND OF 16 (8 partidos) ─────────────────────────────────────────────────
|
||||||
|
const r16Dates = [
|
||||||
|
new Date('2026-07-13T18:00:00Z'), new Date('2026-07-13T21:00:00Z'),
|
||||||
|
new Date('2026-07-14T18:00:00Z'), new Date('2026-07-14T21:00:00Z'),
|
||||||
|
new Date('2026-07-15T18:00:00Z'), new Date('2026-07-15T21:00:00Z'),
|
||||||
|
new Date('2026-07-16T18:00:00Z'), new Date('2026-07-16T21:00:00Z'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: 89 + i },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: 89 + i,
|
||||||
|
phase: Phase.ROUND_OF_16,
|
||||||
|
homeLabel: `Ganador partido ${73 + i * 2}`,
|
||||||
|
awayLabel: `Ganador partido ${74 + i * 2}`,
|
||||||
|
scheduledAt: r16Dates[i],
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QUARTER FINALS (4 partidos) ───────────────────────────────────────────────
|
||||||
|
const qfDates = [
|
||||||
|
new Date('2026-07-18T18:00:00Z'), new Date('2026-07-18T21:00:00Z'),
|
||||||
|
new Date('2026-07-19T18:00:00Z'), new Date('2026-07-19T21:00:00Z'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: 97 + i },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: 97 + i,
|
||||||
|
phase: Phase.QUARTER_FINALS,
|
||||||
|
homeLabel: `Ganador partido ${89 + i * 2}`,
|
||||||
|
awayLabel: `Ganador partido ${90 + i * 2}`,
|
||||||
|
scheduledAt: qfDates[i],
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SEMI FINALS (2 partidos) ──────────────────────────────────────────────────
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: 101 },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: 101,
|
||||||
|
phase: Phase.SEMI_FINALS,
|
||||||
|
homeLabel: 'Ganador partido 97',
|
||||||
|
awayLabel: 'Ganador partido 98',
|
||||||
|
scheduledAt: new Date('2026-07-22T21:00:00Z'),
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: 102 },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: 102,
|
||||||
|
phase: Phase.SEMI_FINALS,
|
||||||
|
homeLabel: 'Ganador partido 99',
|
||||||
|
awayLabel: 'Ganador partido 100',
|
||||||
|
scheduledAt: new Date('2026-07-23T21:00:00Z'),
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── TERCER PUESTO ─────────────────────────────────────────────────────────────
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: 103 },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: 103,
|
||||||
|
phase: Phase.THIRD_PLACE,
|
||||||
|
homeLabel: 'Perdedor Semi 1',
|
||||||
|
awayLabel: 'Perdedor Semi 2',
|
||||||
|
scheduledAt: new Date('2026-07-25T18:00:00Z'),
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── FINAL ─────────────────────────────────────────────────────────────────────
|
||||||
|
await prisma.match.upsert({
|
||||||
|
where: { matchNumber: 104 },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
matchNumber: 104,
|
||||||
|
phase: Phase.FINAL,
|
||||||
|
homeLabel: 'Ganador Semi 1',
|
||||||
|
awayLabel: 'Ganador Semi 2',
|
||||||
|
scheduledAt: new Date('2026-07-26T20:00:00Z'),
|
||||||
|
status: MatchStatus.SCHEDULED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('104 matches seeded.')
|
||||||
|
|
||||||
|
// ── ADMIN USER ────────────────────────────────────────────────────────────────
|
||||||
|
const adminPassword = await bcrypt.hash('admin2026', 12)
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@prode.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@prode.com',
|
||||||
|
password: adminPassword,
|
||||||
|
name: 'Admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Admin user: admin@prode.com / admin2026')
|
||||||
|
console.log('Seed complete!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(e => { console.error(e); process.exit(1) })
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Prode Mundial 2026",
|
||||||
|
"short_name": "Prode",
|
||||||
|
"description": "Pronosticá los partidos del Mundial 2026",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#14532d",
|
||||||
|
"theme_color": "#16a34a",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#f0fdf4',
|
||||||
|
100: '#dcfce7',
|
||||||
|
500: '#22c55e',
|
||||||
|
600: '#16a34a',
|
||||||
|
700: '#15803d',
|
||||||
|
800: '#166534',
|
||||||
|
900: '#14532d',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-prode}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-prode_secret}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-prode_db}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-prode}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-prode}:${POSTGRES_PASSWORD:-prode_secret}@db:5432/${POSTGRES_DB:-prode_db}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-cambia_este_secret_en_produccion}
|
||||||
|
RESULTS_API_URL: ${RESULTS_API_URL:-}
|
||||||
|
RESULTS_API_KEY: ${RESULTS_API_KEY:-}
|
||||||
|
ADMIN_SYNC_SECRET: ${ADMIN_SYNC_SECRET:-sync_secret_cambialo}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
Reference in New Issue
Block a user