Initial commit - Prode Mundial 2026
This commit is contained in:
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user