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
+35
View File
@@ -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"]
+23
View File
@@ -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
+43
View File
@@ -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).*)',
],
}
+15
View File
@@ -0,0 +1,15 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'flagcdn.com',
},
],
},
}
export default nextConfig
+41
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config
+77
View File
@@ -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])
}
+103
View File
@@ -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
}
+312
View File
@@ -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())
+21
View File
@@ -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"
}
]
}
+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',
]
+27
View File
@@ -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
+22
View File
@@ -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"]
}